NB! This documentation is for the 1.0 release. To see docs for 0.28, click here.
To see the docs for the latest release, 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
]
})
})
To access these actions and values from React you either:
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:
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.
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?
You can connect logic with one another. For example to:
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:
- ... with the actions
setFoo
andsetMoo
- ... with reducers for
foo
andmoo
(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:
- Passed to the logic as arguments when using hooks
Use the format:
useActions(logic(props))
- 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 mountedafterMount()
- runs after the logic was mountedbeforeUnmount()
- runs before the logic is unmountedafterUnmount()
- 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 debugconstants
- for when you need a place to store enumsactionCreators
- raw redux actions without dispatchselectors
- raw reselect selectors, abstracted away byvalues
, but there if you need themdefaults
- set default values for reducers by selecting data from props or other logiccache
- 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
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
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
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