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, redux-saga and reselect.
  • No boilerplate: Forget mapStateToProps and redundant constants. Only write code that matters!
  • No new concepts: Use actions, reducers, selectors and sagas. Gradually migrate existing Redux applications.
  • Interoperability: Seamlessly connect different parts of your application.

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

Read the guide or check out the examples below:

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

Slider

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

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 8 repositories for user keajs!
keajs/kea - 917 stars, 19 forks.
keajs/kea-on-rails - 5 stars, 0 forks.
keajs/kea-example - 2 stars, 1 forks.
keajs/kea-parallel - 2 stars, 0 forks.
keajs/kea-website - 2 stars, 2 forks.
keajs/kea-cli - 0 stars, 0 forks.
keajs/kea-parallel-loader - 0 stars, 0 forks.
keajs/kea-rails-loader - 0 stars, 0 forks.

Read the guide: Github

Debounced countdown

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