
A kea
is two things:
- An extremely smart mountain parrot from New Zealand.
- An equally smart architecture for frontend webapps, built on top of React and Redux.
What's included?
-
kea/logic
- Redux Logic Stores. Think of it as an ES6+ data-import system built on top of Redux.
-
kea/saga
- ES6 classes for readable side effects.
-
kea/scene
- Logic + Sagas = Scenes. Includes routing and code splitting.
-
kea/cli
- Scaffolding. Easy project and code generation.
Try it out
Open the demo app, then edit it live in Gomix or view its source on GitHub.
Logic stores
Logic stores consist of actions, reducers and selectors. They include prop types and look like this:
import Logic, { initLogic } from 'kea/logic'
import { PropTypes } from 'react'
@initLogic
export default class HomepageLogic extends Logic {
path = () => ['scenes', 'homepage', 'index']
actions = ({ constants }) => ({
updateName: (name) => ({ name }),
increaseAge: (amount = 1) => ({ amount }),
decreaseAge: (amount = 1) => ({ amount })
})
reducers = ({ actions, constants }) => ({
name: ['Chirpy', PropTypes.string, {
[actions.updateName]: (state, payload) => payload.name
}],
age: [3, PropTypes.number, { persist: true }, {
[actions.increaseAge]: (state, payload) => state + payload.amount,
[actions.decreaseAge]: (state, payload) => Math.max(state - payload.amount, 1)
}]
})
selectors = ({ selectors, constants }) => ({
capitalizedName: [
() => [ selectors.name ],
(name) => name.trim().split(' ').map(k => `${k.charAt(0).toUpperCase()}${k.slice(1).toLowerCase()}`).join(' '),
PropTypes.string
],
description: [
() => [ selectors.capitalizedName, selectors.age ],
(capitalizedName, age) => `Hello, I'm ${capitalizedName}, a ${age} years old bird!`,
PropTypes.string
]
})
}
Note: all of this can be done without @decorators
. Here's how.
Check out the TodoMVC logic.js for a longer example.
Once imported, logic stores expose familiar concepts:
import homepageLogic from '~/scenes/homepage/logic'
homepageLogic.path === ['scenes', 'homepage', 'index']
homepageLogic.selector === (state) => state.scenes.homepage.index
homepageLogic.actions === { updateName: (name) => { ... }, increaseAge: (amount) => { ... }, ... }
homepageLogic.reducer === function (state, action) { ... }
homepageLogic.selectors === { name: (state) => state.scenes.homepage.index.name, capitalizedName: ... }
Component
Let's @connect
to a React component:
import { connect } from 'kea/logic'
import Slider from '~/scenes/homepage/slider'
import sceneLogic from '~/scenes/homepage/logic'
import sliderLogic from '~/scenes/homepage/slider/logic'
@connect({
actions: [
sceneLogic, [
'updateName'
]
],
props: [
sceneLogic, [
'name',
'capitalizedName'
],
sliderLogic, [
'currentSlide',
'currentImage'
]
]
})
export default class HomepageScene extends Component {
updateName = () => {
const { name } = this.props
const { updateName } = this.props.actions
const newName = window.prompt('Please enter the name', name)
if (newName) {
updateName(newName)
}
}
render () {
const { capitalizedName, currentSlide, currentImage } = this.props
return (
<div className='homepage-scene'>
<Slider />
<h1>
Hello, I am <em onClick={this.updateName}>{capitalizedName}</em> the Kea
</h1>
<p>
You are viewing image #{currentSlide + 1}, taken by <a href={currentImage.url}>{currentImage.author}</a>
</p>
</div>
)
}
}
Note: all of this can be done without @decorators
. Here's how.
Side effects (API calls, etc)
kea/saga
provides a Saga
class for beautiful orchestration of side effects via redux-saga.
import Saga from 'kea/saga'
import sceneLogic from '~/scenes/homepage/logic'
import sliderLogic from '~/scenes/homepage/slider/logic'
export default class HomepageSaga extends Saga {
actions = () => ([
sceneLogic, [
'updateName',
'increaseAge',
'decreaseAge'
],
sliderLogic, [
'updateSlide'
]
])
takeEvery = ({ actions }) => ({
[actions.updateName]: this.nameLogger,
[actions.increaseAge]: this.ageLogger,
[actions.decreaseAge]: this.ageLogger
})
run = function * () {
const { updateSlide } = this.actions
while (true) {
const { timeout } = yield race({
change: take(updateSlide),
timeout: delay(5000)
})
if (timeout) {
const currentSlide = yield sliderLogic.get('currentSlide')
yield put(updateSlide(currentSlide + 1))
}
}
}
cancelled = function * () {
console.log('Closing saga')
}
nameLogger = function * (action) {
const { name } = action.payload
console.log(`The name changed to: ${name}!`)
}
ageLogger = function * (action) {
const age = yield sceneLogic.get('age')
console.log(`The age changed to: ${age}!`)
}
}
Read the documentation for redux-saga
or check out
another example!
Hook them into kea/scene
(explained below) or call from your existing code like this:
const homepageSaga = new HomepageSaga().init()
yield call(homepageSaga)
Scenes
You can use all the logic store reducers and sagas individually in your existing application.
If, however, you favor convenience, you may combine them into scenes.
Scenes are defined in scene.js
files like so:
import { createScene } from 'kea/scene'
import sceneComponent from '~/scenes/homepage/index'
import sceneLogic from '~/scenes/homepage/logic'
import sceneSaga from '~/scenes/homepage/saga'
import sliderLogic from '~/scenes/homepage/slider/logic'
import sliderSaga from '~/scenes/homepage/slider/saga'
export default createScene({
name: 'homepage',
component: sceneComponent,
logic: [
sceneLogic,
sliderLogic
],
sagas: [
sceneSaga,
sliderSaga
]
})
You may then access the combined scene like so:
import homepageScene from '~/scenes/homepage'
homepageScene.saga === function * () { ... }
homepageScene.combineReducers() === function (state, action) {}
or plug it into the kea-logic routing helpers.
Routing
Give redux-router
a helping hand:
import { combineScenesAndRoutes } from 'kea/scene'
const scenes = {
homepage: require('bundle?lazy&name=homepage!./homepage/scene.js'),
todos: require('bundle?lazy&name=todos!./todos/scene.js')
}
const routes = {
'/': 'homepage',
'/todos': 'todos',
'/todos/:visible': 'todos'
}
export default combineScenesAndRoutes(scenes, routes)
... and have those scenes lazily loaded when route is accessed.
Code structure
While logic stores can exist anywhere, it is highly recommended to organise your code like this:
scenes
- a scene is a page or a subsystem in your appcomponents
- react components that are shared between scenesutils
- javascript utils shared between scenes
Side note: as we strive for simplicity, readability and clarity, we will use JavaScript Standard Style and skip semicolons. They are added/removed as needed in the transpiling/minimising stage, and add no value. Any "I forgot the semicolon" errors you might be worried about will be caught by the linter anyway. (Please install eslint and plugins for your IDE!)
Here's a typical structure:
scenes/homepage/
- index.js # the react component
- logic.js # actions, reducers, selectors
- saga.js # saga
- styles.scss # styles for this scene
- scene.js # one of these per scene
scenes/homepage/slider/
- _assets/ # some images
- index.js # the react component
- logic.js # actions, reducers, selectors
- saga.js # saga
- styles.scss # styles for the slider
scenes/todos/
- index.js # the react component
- logic.js # actions, reducers, selectors
- saga.js # saga
- styles.scss # styles for this scene
- scene.js # one of these per scene
scenes/todos/todo/
- index.js # the react component
components/
- header/
- index.js
- styles.scss
utils/
- create-uuid.js
- range.js
- delay.js
scenes/
- index.js
- routes.js
- styles.scss
/
- index.html
- index.js
- store.js
Scaffolding
Open the demo app and browse its code.
To run the same example app on your machine, just type these commands:
npm install kea -g
kea new my-project
cd my-project
npm install # or yarn
npm start
and open http://localhost:2000/.
Later inside my-project
run these to hack away:
kea g scene-name # new scene
kea g scene-name/component-name # component under the scene
kea g scene-name/component-name/really-nested # deeply nested logic
More documentation coming soon! Please help if you can!