Skip to main content

Kea 3.0: Logic Builders

ยท 10 min read
Marius Andra

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 LogicBuilders:

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.

Questions & Answers