This documentation is for the old Kea 0.28. To see the latest docs, click here!

Kea

High level abstraction between React and Redux
This documentation is for the old Kea 0.28. For the latest documentation (1.0 and newer), see here!

What is Kea?

Kea is a state management library for React. It empowers Redux, making it as easy to use as setState while retaining composability and improving code clarity.

  • 100% Redux: Built on top of redux and reselect.
  • Side effect agnostic: use thunks with redux-thunk, sagas with redux-saga or (soon!) epics with redux-observable.
  • Wrappable: Write logic alongside React components. Easier than setState and perfect for small components.
  • Connectable: Pull in data and actions through ES6+ imports. Built for large and ambitious apps.
  • No boilerplate: Forget mapStateToProps and redundant constants. Only write code that matters!
  • No new concepts: Use actions, reducers and selectors. Gradually migrate existing Redux applications.

Compare it to other state management libraries: Kea vs setState, Redux, Mobx, Dva, JumpState, Apollo, etc.

Thank you to our sponsors!

Support this project by becoming a sponsor.

Your logo will show up here and in the README with a link to your website.

How does it work?

In Kea, you define logic stores with the kea({}) function.

Each logic store contains actions, reducers and selectors.

kea({
  actions: ({}) => ({ }),
  reducers: ({ actions }) => ({ }),
  selectors: ({ selectors }) => ({ })
})

They work just like in Redux:

  • They are all pure functions (no side effects, same input = same output)
  • Actions are functions which take an input and return a payload
  • Reducers take actions as input and return new_data = old_data + payload
  • Selectors take the input of multiple reducers and return a combined output

See here for a nice overview of how Redux works: Redux Logic Flow — Crazy Simple Summary

For example, to build a simple counter:

kea({
  actions: () => ({
    increment: (amount) => ({ amount }),
    decrement: (amount) => ({ amount })
  }),

  reducers: ({ actions }) => ({
    counter: [0, PropTypes.number, {
      [actions.increment]: (state, payload) => state + payload.amount,
      [actions.decrement]: (state, payload) => state - payload.amount
    }]
  }),

  selectors: ({ selectors }) => ({
    doubleCounter: [
      () => [selectors.counter],
      (counter) => counter * 2,
      PropTypes.number
    ]
  })
})

The logic stores can either

1) be wrapped around your component or pure function:

const logic = kea({ /* options from above */ })

class Counter extends Component {
  render () {
    const { counter, doubleCounter } = this.props
    const { increment, decrement } = this.actions

    return <div>...</div>
  }
}

export default logic(Counter)

2) used as decorators:

@kea({ /* options from above */ })
export default class Counter extends Component {
  render () {
    return <div />
  }
}

or

3) imported and then connected to.

You can also connect logic stores together, to e.g:

  • ... use actions from one logic store in the reducer of another.
  • ... combine reducers from multiple logic stores into one selector.
// features-logic.js
import { kea } from 'kea'
export default kea({ /* options from above */ })

// index.js
import { connect } from 'kea'
import featuresLogic from 'features-logic'

@connect({
  actions: [
    featuresLogic, [
      'increment',
      'decrement'
    ]
  ],
  props: [
    featuresLogic, [
      'counter',
      'doubleCounter'
    ]
  ]
})
export default class Counter extends Component {
  render () {
    return <div />
  }
}

Eventually you'll need side effects. Then you have a choice.

You can use simple thunks via redux-thunk:

import 'kea-thunk'
import { kea } from 'kea'

const incrementerLogic = kea({
  actions: () => ({
    increase: true
  }),
  reducers: ({ actions }) => ({
    counter: [0, PropTypes.number, {
      [actions.increase]: (state, payload) => state + 1
    }]
  }),
  thunks: ({ actions, dispatch, getState }) => ({
    increaseAsync: async (ms) => {
      await delay(ms)
      await actions.increase()
    }
  })
})

.... or the more powerful sagas via redux-saga.

(coming soon: support for epics with redux-observable)

Check out the examples below or start reading the guide for more.

If you're already using Redux in your apps, it's really easy to migrate.

import 'kea-saga'
import { kea } from 'kea'

const sliderLogic = kea({
  actions: () => ({
    updateSlide: index => ({ index })
  }),

  reducers: ({ actions, key }) => ({
    currentSlide: [0, PropTypes.number, {
      [actions.updateSlide]: (state, payload) => payload.index % images.length
    }]
  }),

  selectors: ({ selectors }) => ({
    currentImage: [
      () => [selectors.currentSlide],
      (currentSlide) => images[currentSlide],
      PropTypes.object
    ]
  }),

  start: function * () {
    const { updateSlide } = this.actions

    while (true) {
      const { timeout } = yield race({
        change: take(updateSlide.toString()),
        timeout: delay(5000)
      })

      if (timeout) {
        const currentSlide = yield this.get('currentSlide')
        yield put(updateSlide(currentSlide + 1))
      }
    }
  }
})

Simple counter

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'

@kea({
  actions: () => ({
    increment: (amount) => ({ amount }),
    decrement: (amount) => ({ amount })
  }),

  reducers: ({ actions }) => ({
    counter: [0, PropTypes.number, {
      [actions.increment]: (state, payload) => state + payload.amount,
      [actions.decrement]: (state, payload) => state - payload.amount
    }]
  }),

  selectors: ({ selectors }) => ({
    doubleCounter: [
      () => [selectors.counter],
      (counter) => counter * 2,
      PropTypes.number
    ]
  })
})
export default class Counter extends Component {
  render () {
    const { counter, doubleCounter } = this.props
    const { increment, decrement } = this.actions

    return (
      <div className='kea-counter'>
        Count: {counter}<br />
        Doublecount: {doubleCounter}<br />
        <button onClick={() => increment(1)}>Increment</button>
        <button onClick={() => decrement(1)}>Decrement</button>
      </div>
    )
  }
}
Count: 0
Doublecount: 0

Read the guide: Counter

Delayed Counter with thunks

import React, { Component } from 'react'
import { kea } from 'kea'
import PropTypes from 'prop-types'

const delay = ms => new Promise(resolve => window.setTimeout(resolve, ms))

@kea({
  actions: () => ({
    increase: true
  }),
  reducers: ({ actions }) => ({
    counter: [0, PropTypes.number, {
      [actions.increase]: (state, payload) => state + 1
    }]
  }),
  thunks: ({ actions, dispatch, getState }) => ({
    increaseAsync: async (ms) => {
      await delay(ms)
      await actions.increase()
    }
  })
})
export default class ThunkCounter extends Component {
  render () {
    const { increase, increaseAsync } = this.actions
    const { counter } = this.props

    return (
      <div style={{textAlign: 'center'}}>
        <div>{counter}</div>
        {[0, 10, 100, 500, 1000, 2000].map(ms => (
          <button key={ms}
                  onClick={() => ms === 0 ? increase() : increaseAsync(ms)}>
            {ms}
          </button>
        ))}
      </div>
    )
  }
}
0

Read the docs: kea-thunk

Play with it in WebpackBin

Slider with sagas

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'

import { take, race, put } from 'redux-saga/effects'

import delay from '~/utils/delay' // promise-based timeout helper
import range from '~/utils/range' // range(3) === [0, 1, 2]

import images from './images'     // array of objects [{ src, author }, ...]

@kea({
  actions: () => ({
    updateSlide: index => ({ index })
  }),

  reducers: ({ actions, key }) => ({
    currentSlide: [0, PropTypes.number, {
      [actions.updateSlide]: (state, payload) => payload.index % images.length
    }]
  }),

  selectors: ({ selectors }) => ({
    currentImage: [
      () => [selectors.currentSlide],
      (currentSlide) => images[currentSlide],
      PropTypes.object
    ]
  }),

  start: function * () {
    const { updateSlide } = this.actions

    while (true) {
      const { timeout } = yield race({
        change: take(updateSlide.toString()),
        timeout: delay(5000)
      })

      if (timeout) {
        const currentSlide = yield this.get('currentSlide')
        yield put(updateSlide(currentSlide + 1))
      }
    }
  }
})
export default class Slider extends Component {
  render () {
    const { currentSlide, currentImage } = this.props
    const { updateSlide } = this.actions

    const title = `Image copyright by ${currentImage.author}`

    return (
      <div className='kea-slider'>
        <img src={currentImage.src} alt={title} title={title} />
        <div className='buttons'>
          {range(images.length).map(i => (
            <span key={i}
                  className={i === currentSlide ? 'selected' : ''}
                  onClick={() => updateSlide(i)} />
          ))}
        </div>
      </div>
    )
  }
}
Image copyright by Kevin Glisson

Read the guide: Sliders

Github with sagas

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'

import { put, call } from 'redux-saga/effects'
import { delay } from 'redux-saga'

const API_URL = 'https://api.github.com'

@kea({
  actions: () => ({
    setUsername: (username) => ({ username }),
    setRepositories: (repositories) => ({ repositories }),
    setFetchError: (message) => ({ message })
  }),

  reducers: ({ actions }) => ({
    username: ['keajs', PropTypes.string, {
      [actions.setUsername]: (_, payload) => payload.username
    }],
    repositories: [[], PropTypes.array, {
      [actions.setUsername]: () => [],
      [actions.setRepositories]: (_, payload) => payload.repositories
    }],
    isLoading: [true, PropTypes.bool, {
      [actions.setUsername]: () => true,
      [actions.setRepositories]: () => false,
      [actions.setFetchError]: () => false
    }],
    error: [null, PropTypes.string, {
      [actions.setUsername]: () => null,
      [actions.setFetchError]: (_, payload) => payload.message
    }]
  }),

  selectors: ({ selectors }) => ({
    sortedRepositories: [
      () => [selectors.repositories],
      (repositories) => repositories.sort(
                          (a, b) => b.stargazers_count - a.stargazers_count),
      PropTypes.array
    ]
  }),

  start: function * () {
    const { setUsername } = this.actions
    const username = yield this.get('username')
    yield put(setUsername(username))
  },

  takeLatest: ({ actions, workers }) => ({
    [actions.setUsername]: workers.fetchRepositories
  }),

  workers: {
    * fetchRepositories (action) {
      const { setRepositories, setFetchError } = this.actions
      const { username } = action.payload

      yield delay(100) // debounce for 100ms

      const url = `${API_URL}/users/${username}/repos?per_page=250`
      const response = yield window.fetch(url)

      if (response.status === 200) {
        const json = yield response.json()
        yield put(setRepositories(json))
      } else {
        const json = yield response.json()
        yield put(setFetchError(json.message))
      }
    }
  }
})
export default class Github extends Component {
  render () {
    const { username, isLoading, repositories,
            sortedRepositories, error } = this.props
    const { setUsername } = this.actions

    return (
      <div className='example-github-scene'>
        <div style={{marginBottom: 20}}>
          <h1>Search for a github user</h1>
          <input value={username}
                 type='text'
                 onChange={e => setUsername(e.target.value)} />
        </div>
        {isLoading ? (
          <div>
            Loading...
          </div>
        ) : repositories.length > 0 ? (
          <div>
            Found {repositories.length} repositories for user {username}!
            {sortedRepositories.map(repo => (
              <div key={repo.id}>
                <a href={repo.html_url} target='_blank'>{repo.full_name}</a>
                {' - '}{repo.stargazers_count} stars, {repo.forks} forks.
              </div>
            ))}
          </div>
        ) : (
          <div>
            {error ? `Error: ${error}` : 'No repositories found'}
          </div>
        )}
      </div>
    )
  }
}

Search for a github user

Found 24 repositories for user keajs!
keajs/kea - 1813 stars, 63 forks.
keajs/kea-website - 13 stars, 12 forks.
keajs/kea-on-rails - 8 stars, 0 forks.
keajs/kea-example-v0.27-old - 8 stars, 1 forks.
keajs/kea-localstorage - 7 stars, 3 forks.
keajs/kea-saga - 7 stars, 4 forks.
keajs/kea-router - 5 stars, 1 forks.
keajs/kea-loaders - 4 stars, 1 forks.
keajs/kea-typegen - 3 stars, 0 forks.
keajs/kea-docs - 3 stars, 8 forks.
keajs/babel-plugin-kea - 3 stars, 0 forks.
keajs/kea-cli - 3 stars, 1 forks.
keajs/kea-waitfor - 3 stars, 1 forks.
keajs/kea-parallel - 3 stars, 0 forks.
keajs/kea-parallel-loader - 2 stars, 0 forks.
keajs/kea-rails-loader - 2 stars, 0 forks.
keajs/kea-test-utils - 2 stars, 0 forks.
keajs/kea-js-redirect - 2 stars, 0 forks.
keajs/kea-forms - 2 stars, 0 forks.
keajs/kea-window-values - 2 stars, 0 forks.
keajs/kea-thunk - 1 stars, 4 forks.
keajs/kea-next-test - 0 stars, 1 forks.
keajs/kea-listeners - 0 stars, 1 forks.

Read the guide: Github

Debounced countdown with sagas

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'

import delay from '~/utils/delay'
import { put, cancelled } from 'redux-saga/effects'

@kea({
  actions: () => ({
    start: true,
    finish: true,
    setCounter: (counter) => ({ counter })
  }),

  reducers: ({ actions, key, props }) => ({
    counter: [0, PropTypes.number, {
      [actions.setCounter]: (_, payload) => payload.counter
    }],
    finished: [false, PropTypes.bool, {
      [actions.start]: () => false,
      [actions.finish]: () => true
    }]
  }),

  takeLatest: ({ actions, workers }) => ({
    [actions.start]: function * () {
      try {
        const { setCounter, finish } = this.actions

        for (let i = 50; i >= 0; i--) {
          yield put(setCounter(i))
          yield delay(50)
        }
        yield put(finish())
      } finally {
        if (yield cancelled()) {
          console.log('Countdown was cancelled!')
        }
      }
    }
  })
})
export default class Countdown extends Component {
  render () {
    const { counter, finished } = this.props
    const { start } = this.actions

    return (
      <div className='kea-counter'>
        Count: {counter}
        <br /><br />
        {finished
          ? 'We made it until the end! finish() action triggered'
          : 'Click start to trigger the finish() action in a few seconds'}
        <br /><br />
        <button onClick={() => start()}>Start</button>
      </div>
    )
  }
}
Count: 0

Click start to trigger the finish() action in a few seconds


Connected logic stores

// features-logic.js
import PropTypes from 'prop-types'
import { kea } from 'kea'

export default kea({
  actions: () => ({
    toggleFeature: (feature) => ({ feature })
  }),
  reducers: ({ actions }) => ({
    features: [{}, PropTypes.object, {
      [actions.toggleFeature]: (state, payload) => {
        const { feature } = payload
        return {
          ...state,
          [feature]: !state[feature]
        }
      }
    }]
  })
})

// index.js
import React, { Component } from 'react'
import { connect } from 'kea'

import featuresLogic from '../features-logic'

@connect({
  actions: [
    featuresLogic, [
      'toggleFeature'
    ]
  ],
  props: [
    featuresLogic, [
      'features'
    ]
  ]
})
export default class ConnectedToggle extends Component {
  render () {
    const { features } = this.props
    const { toggleFeature } = this.actions

    return (
      <div>
        <p>{features.something ? 'Something enabled' : 'Something disabled'}</p>
        <button onClick={() => toggleFeature('something')}>Toggle something</button>
      </div>
    )
  }
}

Something disabled


Read the guide: Connected