Introducing Kea v3โ
Since its origins in 2016 (it's been six years???), Kea has been on a mission to simplify frontend development, keeping pace, and adapting with the technological winds of change, as needed.
Since the last big rewrite in 2019, things have changed again. React 18 introduced Concurrent Mode. ECMAScript modules are in the browser. TypeScript is in all the things.
Kea's syntax hasn't kept up with the way Kea was being used, passing everything through a huge object keeps getting in the way of extensibility, and it's time for a refresh. It's 2022 after all.
Everything old will keep working, but here's the new:
// Kea 3.0
import { kea, actions, reducers, listeners, useActions } from 'kea'
import { loaders } from 'kea-loaders'
import { githubLogicType } from './githubLogicType'
export const githubLogic = kea<githubLogicType>([
actions({
setUsername: (username: string) => ({ username }),
}),
reducers({
username: ['keajs', { setUsername: (_, { username }) => username }],
}),
loaders({
repositories: [null, { setUsername: ({ username }) => api.getRepos(username) }],
}),
])
export function Github(): JSX.Element {
const { username, repositories } = useValues(githubLogic)
const { setUsername } = useActions(githubLogic)
return (
<>
<input value={userName} onChange={(e) => setUsername(e.target.value)} />
<div>repos: {repositories.map((r) => r.name).join(', ')}</div>
</>
)
}
Can you spot the difference?
// Kea 2.x
import { kea } from 'kea'
import { githubLogicType } from './githubLogicType'
export const githubLogic = kea<githubLogicType>({
actions: {
setUsername: (username: string) => ({ username }),
},
reducers: {
username: ['keajs', { setUsername: (_, { username }) => username }],
},
loaders: {
repositories: [null, { setUsername: ({ username }) => api.getRepos(username) }],
},
})
export function Github(): JSX.Element {
const { username, repositories } = useValues(githubLogic)
const { setUsername } = useActions(githubLogic)
return (
<>
<input value={userName} onChange={(e) => setUsername(e.target.value)} />
<div>repos: {repositories.map((r) => r.name).join(', ')}</div>
</>
)
}
The old "it is not legacy" 2.0 syntax is guaranteed to be supported until at least January 19th, 2038.
The new "it already feels more solid" 3.0 syntax is called "Logic Builders", and it brings a few surprising benefits.
You pass kea
an array of LogicBuilder
s:
import { kea, actions, reducers, listeners, useActions } from 'kea'
import { loaders } from 'kea-loaders'
const logic = kea([
// put the `LogicBuilder`-s here ๐
actions({}),
reducers({}),
loaders({}),
])
And get a logic
in return.
But why are the logic builders in an array, and why is this syntax better than the old one?
Let's explore.
Logic Buildersโ
Each logic builder is nothing more than a function that modifies the logic
.
function actions(input) {
return (logic) => {
// do something to `logic`, based on `input`
}
}
Here's a peek inside the core actions
builder to show how un-magical it all is:
function actions<L, I>(input: I): LogicBuilder<L> {
return (logic) => {
for (const [key, payload] of input) {
logic.actionsCreators[key] = createAction(key, payload)
logic.actions[key] = (...args: any[]) => dispatch(logic.actionsCreators[key](...args))
// etc...
}
}
}
The core logic builders are: actions
, defaults
, events
, listeners
, reducers
, selectors
.
While putting logic builders in an array to create logic
is great fun, their real power comes from the realisation that logic builders can call other logic builders! ๐ก
With this insight, you can build all sorts of clever and highly practical abstractions, like loaders
and forms
:
const logic = kea([
forms({
loginForm: {
defaults: { user: '', pass: '' },
errors: ({ user, pass }) => ({
user: !user ? 'Please enter a user' : '',
pass: !pass ? 'Please enter a password' : '',
}),
submit: ({ user, pass }) => {
authLogic.actions.initLogin(user, pass)
}
},
})
])
export function forms<L extends Logic = Logic>(
input: FormDefinitions<L> | ((logic: BuiltLogic<L>) => FormDefinitions<L>),
): LogicBuilder<L> {
return (logic) => {
const forms = typeof input === 'function' ? input(logic) : input
for (const [formKey, formObject] of Object.entries(forms)) {
const capitalizedFormKey = capitalizeFirstLetter(formKey)
actions({
[`set${capitalizedFormKey}Value`]: (name: FieldName, value: any) => ({ name, value }),
[`reset${capitalizedFormKey}`]: (values?: Record<string, any>) => ({ values }),
[`submit${capitalizedFormKey}`]: true,
[`submit${capitalizedFormKey}Success`]: (formValues: Record<string, any>) => ({ [formKey]: formValues }),
[`submit${capitalizedFormKey}Failure`]: (error: Error) => ({ error }),
})(logic)
if (formObject.defaults) {
defaults({
[formKey]: formObject.defaults,
})(logic)
}
reducers({
[formKey]: {
[`set${capitalizedFormKey}Value`]: (
state: Record<string, any>,
{ name, value }: { name: FieldName; value: any },
) => deepAssign(state, name, value),
[`reset${capitalizedFormKey}`]: (state: Record<string, any>, { values }: { values: Record<string, any> }) =>
values || formObject.defaults || {},
},
// and so on
To learn more, read through the completely revamped documentation, starting with "What Is Kea?"
Logic Builder Codemodโ
To automatically convert all logic into the new syntax, run:
npx kea-typegen@next write --convert-to-builders
New Featuresโ
note
New to Kea? Start by reading the What is Kea page. The rest of this blog posts lists the differences between v2 and v3.
The official kea-forms
pluginโ
As hinted earlier, there's a new plugin that makes web forms spark joy again: kea-forms
import { kea } from 'kea'
import { forms, Form, Field } from 'kea-forms'
const loginLogic = kea([
forms({
loginForm: {
defaults: { user: '', pass: '' },
errors: ({ user, pass }) => ({
user: !user ? 'Please enter a user' : '',
pass: !pass ? 'Please enter a password' : '',
}),
submit: ({ user, pass }) => {
authLogic.actions.initLogin(user, pass)
},
},
}),
])
export function LoginForm(): JSX.Element {
return (
<Form logic={loginLogic} formKey="loginForm" enableFormOnSubmit>
{/* `value` and `onChange` are passed automatically to children of <Field> */}
<Field name="user">
<input type="text" />
</Field>
<Field name="pass">
<input type="password" />
</Field>
<button type="submit">Login!</button>
</Form>
)
}
Explicit afterMount
and beforeUnmount
buildersโ
While events({ afterMount: () => {} })
works like before, you can now use afterMount
and beforeUnmount
directly.
Here's a logic that flips a message once per second, for as long as it's mounted:
import { actions, afterMount, beforeUnmount, kea, reducers } from 'kea'
const pingPongLogic = kea([
// create a simple counter
actions({ increment: true }),
reducers({ counter: [0, { increment: (state) => state + 1 }] }),
selectors({ message: [(s) => [s.counter], (counter) => (counter % 2 ? 'ping' : 'pong')] }),
// make it dance
afterMount(({ actions, cache }) => {
cache.interval = window.setInterval(actions.increment, 1000)
}),
beforeUnmount(({ cache }) => {
window.clearInterval(cache.interval)
}),
])
New propsChanged
eventโ
Instead of hacky useEffect
loops, there's a new way to sync props from React to kea: the propsChanged
event,
which fires whenever React calls a logic with a new set of props.
Here's an over-engineered textfield that's controlled directly through props
.
import React from 'react'
import {
kea,
actions,
reducers,
listeners,
props,
propsChanged,
path,
useValues,
useActions,
} from 'kea'
import type { textFieldLogicType } from './TextFieldType'
interface TextFieldProps {
value: string
onChange?: (value: string) => void
}
const textFieldLogic = kea<textFieldLogicType<TextFieldProps>>([
props({ value: '', onChange: undefined } as TextFieldProps),
actions({ setValue: (value: string) => ({ value }) }),
reducers(({ props }) => ({ value: [props.value, { setValue: (_, { value }) => value }] })),
listeners(({ props }) => ({ setValue: ({ value }) => props.onChange?.(value) })),
propsChanged(({ actions, props }, oldProps) => {
if (props.value !== oldProps.value) {
actions.setValue(props.value)
}
}),
])
export function TextField(props: TextFieldProps) {
const { value } = useValues(textFieldLogic(props))
const { setValue } = useActions(textFieldLogic(props))
return <input value={value} onChange={(e) => setValue(e.target.value)} />
}
New subscriptions
pluginโ
When listeners listen to actions, subscriptions listen to values. You can now run code when a value changes, no matter where the change originated from:
import { kea, actions, reducers } from 'kea'
import { subscriptions } from 'kea-subscriptions'
const logic = kea([
actions({ setMyValue: (value) => ({ value }) }),
reducers({ myValue: ['default', { setMyValue: (_, { value }) => value }] }),
subscriptions({ myValue: (value, oldValue) => console.log({ value, oldValue }) }),
])
logic.mount()
// [console.log] { value: 'default', oldValue: undefined }
logic.actions.setMyValue('coffee')
// [console.log] { value: 'coffee', oldValue: 'default' }
logic.actions.setMyValue('bagels')
// [console.log] { value: 'bagels', oldValue: 'coffee' }
useSelector
โ
There's a new useSelector
hook that works just like the one from react-redux
import { useSelector } from 'kea'
function Component() {
const value = useSelector((state) => state.get.my.value)
return <em>{value}</em>
}
Breaking changesโ
No more peer dependenciesโ
Feel free to remove redux
, react-redux
and reselect
from your dependencies, unless you're using them directly.
Kea 3.0 removes react-redux
(replaced via React 18's useSynExternalStore
and its shim for older versions),
and includes its dependencies redux
and reselect
directly. The kea
package, and the various plugins, are all you need.
No more <Provider />
โ
It's no longer necessary to wrap your app in a <Provider />
tag.
If you're using react-redux
's useSelector
, switch to Kea's useSelector
that doesn't need to be inside a <Provider />
.
If you still need the tag for interoperability with non-kea Redux code, use react-redux
's Provider
with <Provider store={getContext().store}>
.
Auto-Connect inside listeners is going awayโ
Kea v2.0 introduced auto-connect, which was mostly a good idea. There's one place it didn't work:
const logic = kea({
listeners: {
getBread: () => {
shopLogic.actions.knockDoor()
},
},
})
Starting with Kea 2.0+, if shopLogic
was not explicitly mounted, it would get mounted when accessed from within a listener.
It was a great idea, but came with subtle bugs due to the async nature of JS, and it's going away.
To safely migrate, upgrade to Kea 2.6.0
, which will warn about automatically mounted logic.
Fix all the notices, and make sure all logic is explicitly connected via connect
, manually mounted, or mounted through a React hook.
You can also call something like userLogic.findMounted()
, to access a logic only if it's mounted.
autoMount: true
is also going awayโ
There's was also an option to automatically mount a logic as soon as it was created. That's going away as well. If you
still need this, make a plugin with an afterLogic
hook.
Props mergeโ
In earlier versions, the last used props overwrote whatever was there. Now props always merge:
const logic = kea([key(({ id }) => id)])
logic({ id: 1, value: 'blup' })
logic({ id: 1, other: true }).props === { id: 1, value: 'blup', other: true }
No more constants
โ
Instead of constants
from kea v2, use TypeScript Enums.
No more PropTypes
โ
All support for prop-types
is dropped. You can no longer pass them to reducers or selectors.
After 6 long years, it's time to bid farewell to this relic of the early days of React.
Removed old connect
โ
Now that we have builders, connect
is the name of an exported builder.
The previous connect
, which was literally defined as:
const connect = (input) => kea({ connect: input })
... is gone. Use the snipped above if you need it.
The old connect
was useful in the Kea v0 days, when React components were classes, and you used old decorators
to connect actions and props values to components.
Those days are gone, and so is the old connect
.
Remove props
from connect
โ
The values
key in connect({ actions: [], values: [] })
used to be called props
. This was renamed to values
and deprecated with Kea 1.0.
Now it's gone.
Remove custom static payloadโ
With Kea 3.0, an action can either be built with true
(no payload) or a payload creator:
kea([
actions({
reset: true,
increment: (amount) => ({ amount }),
}),
])
Earlier versions allowed anything instead of true
, and used that as the payload. If you still need that, just convert
it into a function.