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