Kea

Data Layer for React. Powered by Redux.

NB! This documentation is for the 1.0 release. To see docs for 0.28, click here.

Why Kea?

The Kea project began when I first started to use Redux in a React app in 2015.

Redux was fine, but I kept writing very similar code over and over again. Eventually I looked for ways to simplify things. I wrote several helper functions that automatised most of these repetitive tasks.

That loose collection of functions grew into the first public release of Kea, version 0.1 at the start of 2016.

Those in turn evolved into a unified high level abstraction over Redux. The small helper functions morphed into a standardised way to describe your app's state and all the logic that manipulates it, including side effects. (versions 0.1 to 0.28 over 3 years).

That worked well. There were plenty of users and businesses who depended on Kea to power their apps. Several of them said very nice things about it.

Then things got complicated.

Recent changes in React and React-Redux combined with community feedback through unsolvable feature requests forced me to take a step back and have a fresh look at what was Kea and where was it heading. It was time for a refactor... Which turned into a rewrite... Which took on a life of its own... and kept expanding and expanding and expanding.

All of this while retaining the same bundle size as before (16kb minified -> 17kb minified).

After 5+ months of hard work over 300+ commits Kea 1.0 was born.

It's a complete rewrite of what came before, taking Kea from being just an abstraction over Redux into proper framework territory.

What is Kea?

Think of React as the User Interface (UI) layer of your application. It takes your application's state and converts it into something the user can interact with. It is exceptionally good at this.

React, however, is unopinionated as to where you actually store this state. While it provides some primitives to get you going (think useState), most apps eventually implement a dedicated state management solution.

Kea is one such solution. It adds a Data Layer to React's UI layer and acts as the brain of your application. There is seamless interoperability between both layers as we are standing on the great work done by the react-redux team.

Kea, however, is more than just a state container. There are plenty of nice features to make any developer happy. Read on to find out more!

How does it work?

In Kea, you create logic from input with the kea() function.

Each logic contains actions, reducers and selectors.

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

They work just like in Redux:

  • Actions request changes in the system
  • Reducers manage your data and change it in response to actions
  • Selectors combine one or more reducers into a new output

They must all be pure functions and perform no side effects.

If this is new to you, see here for a nice overview of how Redux works.

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

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

  selectors: ({ selectors }) => ({
    doubleCounter: [
      () => [selectors.counter],
      (counter) => counter * 2
    ]
  })
})
const logic = kea({ ... })

export default function Counter () {
  const { increment, decrement } = useActions(logic)
  const { counter, doubleCounter } = useValues(logic)

  return (
    <div>
      <p>Counter: {counter}</p>
      <p>DoubleCounter: {doubleCounter}</p>
      <p>
        <button onClick={() => increment(1)}>+</button>
        <button onClick={() => decrement(1)}>-</button>
      </p>
    </div>
  )
}

Eventually you'll need side effects (e.g. to talk to your API). Then you have a choice:

  1. You can use listeners via kea-listeners

    Listeners are functions that run after the action they are listening to is dispatched.

    They have built in support for cancellation and debouncing through breakpoints.

  2. You can use thunks via kea-thunk & redux-thunk

  3. You can use sagas via kea-saga & redux-saga

const incrementerLogic = kea({
  actions: () => ({
    increase: true,
    debouncedIncrease: ms => ({ ms })
  }),

  reducers: ({ actions }) => ({
    counter: [0, {
      [actions.increase]: state => state + 1
    }]
  }),

  listeners: ({ actions, values, store }) => ({
    [actions.debouncedIncrease]: async ({ ms }, breakpoint) => {
      // return if the action was called again while sleeping
      await breakpoint(ms)
      actions.increase()

      console.log(`Current state: ${values.counter}`)
    }
  })
})

Any other interesting plugins?

Yes! There are many other plugins you can extend your logic with.

For example kea-dimensions, which sets a value based on the screen dimensions.

import { useValues } from 'kea'

const logic = kea({
  dimensions: {
    isSmallScreen: [false, window => window.innerWidth < 640],
    isLargeScreen: [true, window => window.innerWidth >= 960]
  },
})

const function Component () {
  const { isSmallScreen } = useValues(logic)

  if (isSmallScreen) {
    return <small>Hello Small World</small>
  }

  return <strong>Hello Big World</strong>
}

... or kea-router, which dispatches actions in response to URL changes... and changes the URL in response to dispatched actions.

kea({
  actions: () => ({
    openArticle: id => ({ id }),
    closeArticle: true
  }),

  reducers: ({ actions }) => ({
    article: [null, {
      [actions.openArticle]: (_, payload) => payload.id,
      [actions.closeArticle]: () => null
    }]
  })

  actionToUrl: ({ actions }) => ({
    [actions.openArticle]: ({ id }) => `/articles/${id}`,
    [actions.closeArticle]: action => '/articles'
  }),

  urlToAction: ({ actions }) => ({
    '/articles/:id': ({ id }) => actions.openArticle(id),
    '/articles': () => actions.closeArticle()
  })
})

What more can Kea do?

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

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

const doubleLogic = kea({
  connect: {
    // reusing the same actions
    actions: [logic, ['increment', 'decrement']]
  }

  reducers: ({ actions }) => ({
    doubleCounter: [0, {
      [actions.increment]: (state, payload) => state + payload.amount * 2,
      [actions.decrement]: (state, payload) => state - payload.amount * 2
    }]
  })
})

You can programmatically create logic.

This example function createGetterSetterLogic(...) creates for the options { foo: "bar", moo: "baz" } logic:

  1. ... with the actions setFoo and setMoo
  2. ... with reducers for foo and moo (defaulting to "bar" and "baz")

You can abstract away repetetive code like this.

See the chapter in the guide about forms for one example of this approach.

function capitalize(name) {
  return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase()
}

function createGetterSetterLogic (options) {
  return kea({
    actions: () => {
      let actions = {}
      Object.keys(options).forEach(key => {
        actions[`set${capitalize(key)}`] = value => ({ [key]: value })
      })
      return actions
    },
    reducers: ({ actions }) => {
      let reducers = {}
      Object.keys(options).forEach(key => {
        reducers[key] = [
          options[key],
          {
            [actions[`set${capitalize(key)}`]]: (_, payload) => payload[key]
          }
        ]
      })
      return reducers
    }
  })
}

const logic = createGetterSetterLogic({
  name: 'React',
  description: 'Frontend bliss'
})

// logic.actions == { setName (value) {}, setDescription (value) {} }
// logic.values == { name: ..., description: ... }

You can extend already created logic through logic.extend({})

Inside logic.extend({}) you use exactly the same syntax as in kea({}).

Split code out of kea({}) blocks into functions that extend them with certain features.

When needed, further abstract these extensions into a plugin.

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

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

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

Cool. Is that it?

No! There's a lot more Kea can do!

For example, if you give your logic a key, you can have multiple independent copies of it.

The key is derived from props, which is either:

  1. Passed to the logic as arguments when using hooks

    Use the format: useActions(logic(props))

  2. Taken from your component's props

Imagine having multiple independent ['image galleries', 'todo items', 'text edit forms', ...] on one page with their own state and actions.

const logic = kea({
  key: (props) => props.id,

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

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

function Counter ({ id }) {
  const { counter, doubleCounter } = useValues(logic({ id }))
  const { increment, decrement } = useActions(logic({ id }))

  return (
    <div>
      Counter {id}: {counter}<br />
      <button onClick={() => increment(1)}>Increment</button>
      <button onClick={() => decrement(1)}>Decrement</button>
    </div>
  )
}

export default function Counters () {
  return (
    <>
      <Counter id={1} />
      <Counter id={2} />
    </>
  )
}

When you use Kea with React, your logic's reducers are automatically added to redux when your component renders and removed when it's destroyed.

However, you can also interact with logic outside React if you mount it manually (or set autoMount to true when initializing Kea).

  • Call logic.mount() to initialize the logic and connect it to redux.
  • Then call logic.actions.doSomething() to dispatch actions
  • ... and use logic.values.something to get the values
  • ... and access everything else that is defined on a built logic.

If your logic uses a key, you must build it first:

const builtLogic = logic({ id: 123 })

And then call builtLogic.mount() to mount it.

// create the counter logic from the examples above
const logic = kea({ ... })

// connect its reducers to redux
const unmount = logic.mount()

logic.values.counter
// => 0

logic.actions.increment()
// => { type: 'increment ...', payload: { amount: 1 } }

logic.values.counter
// => 1

// remove reducers from redux
unmount()

logic.values.counter
// => throw new Error()!

Events.

A logic has 4 events that you can hook into:

  • beforeMount() - runs before the logic is mounted
  • afterMount() - runs after the logic was mounted
  • beforeUnmount() - runs before the logic is unmounted
  • afterUnmount() - runs after the logic was unmounted

We recommend keeping your events light and only dispatching actions from them.

These actions should then be caught by reducers or listeners which do whatever is needed.

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

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

  events: ({ actions }) => ({
    afterMount () {
      actions.increment()
    }
  })
})

Okay, that must be it with the features?

Almost! There are few more concepts and keywords that we didn't cover yet:

  • path - to make it easier to debug
  • constants - for when you need a place to store enums
  • actionCreators - raw redux actions without dispatch
  • selectors - raw reselect selectors, abstracted away by values, but there if you need them
  • defaults - set default values for reducers by selecting data from props or other logic
  • cache - a transient object for storing temporary data in plugins
  • how to create plugins
  • the kea context and plugin contexts
  • using props in selectors
  • initialized vs built vs mounted logic

... to name a few.

Check out the examples below or start reading the docs to learn more!

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

Simple counter

import React from 'react'
import { kea, useActions, useValues } from 'kea'

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

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

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

function Counter () {
  const { counter, doubleCounter } = useValues(counterLogic)
  const { increment, decrement } = useActions(counterLogic)

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

export default Counter
Count: 0
Doublecount: 0

Delayed Counter with kea-listeners

import React from 'react'
import { kea, useActions, useValues } from 'kea'

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

const logic = kea({
  actions: () => ({
    increase: true,
    increaseAsync: ms => ({ ms }),
    toggleDebounced: true
  }),
  reducers: ({ actions }) => ({
    counter: [0, {
      [actions.increase]: state => state + 1
    }],
    debounced: [false, {
      [actions.toggleDebounced]: state => !state
    }]
  }),
  listeners: ({ actions, values }) => ({
    [actions.increaseAsync]: async ({ ms }, breakpoint) => {
      if (values.debounced) {
        await breakpoint(ms) // breaks if called again while waiting
      } else {
        await delay(ms) // does not break
      }
      actions.increase()
    }
  })
})

function ListenerCounter () {
  const { counter, debounced } = useValues(logic)
  const { increase, increaseAsync, toggleDebounced } = useActions(logic)

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

export default ListenerCounter
0

Read more about kea-listeners

Github with kea-listeners

import React from 'react'
import { kea, useActions, useValues } from 'kea'

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

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

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

  selectors: ({ selectors }) => ({
    sortedRepositories: [
      () => [selectors.repositories],
      (repositories) => {
        const sorter = (a, b) => b.stargazers_count - a.stargazers_count
        return [...repositories].sort(sorter)
      }
    ]
  }),

  events: ({ actions, values }) => ({
    afterMount () {
      actions.setUsername(values.username)
    }
  }),

  listeners: ({ actions }) => ({
    [actions.setUsername]: async function ({ username }, breakpoint) {
      const { setRepositories, setFetchError } = actions

      await breakpoint(100) // debounce for 100ms

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

      breakpoint() // break if action was called while we were fetching

      const json = await response.json()

      if (response.status === 200) {
        setRepositories(json)
      } else {
        setFetchError(json.message)
      }
    }
  })
})

function Github () {
  const {
    username, isLoading, repositories, sortedRepositories, error
  } = useValues(githubLogic)
  const { setUsername } = useActions(githubLogic)

  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>
  )
}

export default Github

Search for a github user

Found 14 repositories for user keajs!
keajs/kea - 1503 stars, 52 forks.
keajs/kea-website - 10 stars, 11 forks.
keajs/kea-example - 6 stars, 2 forks.
keajs/kea-on-rails - 5 stars, 0 forks.
keajs/kea-localstorage - 4 stars, 1 forks.
keajs/kea-saga - 4 stars, 3 forks.
keajs/kea-parallel - 2 stars, 0 forks.
keajs/kea-cli - 1 stars, 1 forks.
keajs/kea-thunk - 1 stars, 3 forks.
keajs/kea-listeners - 0 stars, 0 forks.
keajs/kea-rails-loader - 0 stars, 0 forks.
keajs/kea-router - 0 stars, 0 forks.
keajs/kea-next-test - 0 stars, 1 forks.
keajs/kea-parallel-loader - 0 stars, 0 forks.

Read the guide: Github API

Slider with kea-saga

import React from 'react'
import { kea, useActions, useHooks } from 'kea'
import { take, race, put, delay } from 'redux-saga/effects'

import range from '~/utils/range' // range(3) === [0, 1, 2]

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

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

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

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

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

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

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

function Slider () {
  const { currentSlide, currentImage } = useValues(sliderLogic)
  const { updateSlide } = useActions(sliderLogic)

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

export default Slider
Image copyright by Kevin Glisson

Read the guide: Sliders