Kea

High level abstraction between React and Redux

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

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 11 repositories for user keajs!
keajs/kea - 1091 stars, 31 forks.
keajs/kea-on-rails - 5 stars, 0 forks.
keajs/kea-website - 3 stars, 5 forks.
keajs/kea-example - 2 stars, 1 forks.
keajs/kea-parallel - 2 stars, 0 forks.
keajs/kea-localstorage - 0 stars, 0 forks.
keajs/kea-cli - 0 stars, 0 forks.
keajs/kea-parallel-loader - 0 stars, 0 forks.
keajs/kea-rails-loader - 0 stars, 0 forks.
keajs/kea-saga - 0 stars, 0 forks.
keajs/kea-thunk - 0 stars, 0 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