Skip to main content

ยท 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.

ยท 2 min read
Marius Andra

Kea 2.6 will be the last 2.x of Kea. Version 3.0 is just around the corner with big changes, and version 2.6 is here to provide a smoother upgrade path.

  • Update version requirements of peer dependencies: reselect 4.1+, redux 4.2+, react-redux 7+ and react 16.8+. If you're using React 18, upgrade react-redux to version 8+.

  • React 18 support: Add a "redux listener silencing store enhancer", which prevents Redux's useSelectors from updating, when mounting a logic from within the body of a React component (e.g. with useValues). This effectively silences log spam in React 18 (Warning: Cannot update a component (Y) while rendering a different component (X). To locate the bad setState() call inside X, follow the stack trace as described.), and improves performance.

  • Support custom selector memoization. Use memoizeOptions as the 4th selector array value, which is then passed directly to reselect:

const logic = kea({
selectors: {
widgetKeys: [
(selectors) => [selectors.widgets],
(widgets) => Object.keys(widgets),
null, // PropTypes, will be removed in Kea 3.0
{ resultEqualityCheck: deepEqual },
],
},
})
  • Set the autoConnectMountWarning option to true by default. Kea 2.0 introduced "auto-connect", and while it works great in reducers and selectors, automatically connecting logic in listeners turned out to be a bad idea. Thus, in Kea 2.6, when accessing values on an unmounted logic, you'll get a warning by default. In Kea 3.0, it will trigger an error.
import { kea } from 'kea'
import { otherLogic } from './otherLogic'
import { yetAnotherLogic } from './yetAnotherLogic'

const logic = kea({
// connect: [otherLogic], // should have been explicitly connected like this, or mounted outside the logic
actions: { doSomething: true },
listeners: {
doSomething: () => {
// This will now print a warning if `otherLogic` is not mounted.
// Either add it to "connect" or make sure it's mounted elsewhere.
console.log(otherLogic.values.situation)
},
},
reducers: {
something: [
null,
{
// This `yetAnotherLogic` will still get connected automatically, not print a warning,
// and not require `connect`. That's because it's connected directly at build time, whereas
// in the listener, we're running within an asynchronous callback coming from who knows where.
// While this works, it's still good practice to explicitly define your dependencies.
[yetAnotherLogic.actionTypes.loadSessions]: () => 'yes',
},
],
},
})

ยท One min read
Marius Andra

Big news, we have a brand new logic testing framework!

Read all about it in the updated Testing guide!

Here's a teaser:

import { expectLogic, partial } from 'kea-test-utils'

it('setting search query loads remote items', async () => {
await expectLogic(logic, () => {
logic.actions.setSearchQuery('event')
})
.toDispatchActions(['setSearchQuery', 'loadRemoteItems'])
.toMatchValues({
searchQuery: 'event',
remoteItems: partial({
count: 0,
results: [],
}),
remoteItemsLoading: true,
})
.toDispatchActions(['loadRemoteItemsSuccess'])
.toMatchValues({
searchQuery: 'event',
remoteItems: partial({
count: 3, // got new results
results: partial([partial({ name: 'event1' })]),
}),
remoteItemsLoading: false,
})
})

Oh, and Kea 2.5 is out as well, featuring logic.isMounted() and a bunch of fixes from the 2.4 series.

ยท One min read
Marius Andra

Finally, files generated with kea-typegen will automatically import any types they can, and add the rest as type arguments for kea<logicType<LocalType, LocalUser>>

You just need to add types to your actions and reducers.

import { Blog } from './blog'
import { logicType } from './logicType'

export const LocalType = 'YES' | 'NO'

const logic = kea<logicType<LocalType>>({ // ๐Ÿ‘ˆ๐Ÿฆœ managed automatically by typegen
actions: {
openBlog: (id: number, blog?: Blog) => ({ id, blog }), // ๐Ÿ‘ˆ add types here
closeBlog: (answer: LocalType) => ({ answer }),
},
reducers: {
blogId: [
null as number | null, // ๐Ÿ‘ˆ null now, but sometimes a number ๐Ÿ™€
{
openBlog: (_, { id }) => id,
closeBlog: () => null,
// use `actionTypes` instead of `actions`
[funLogic.actionTypes.randomBlogPage]: () => 4, // chosen by a fair dice roll
},
],
},
listeners: () => ({
closeBlog: ({ answer }) => { // no types needed here
console.log(answer)
}
})
})

Read the updated TypeScript guide to learn more.

ยท 5 min read
Marius Andra

Back in 2015, shortly after learning about React and Redux, I fell in love with the functional programming paradigms behind them because of what they enabled.

By following a few principles of immutability and purity, React frontends were generally better written, stabler and easier to debug, compared to contemporary alternatives such as Ember or Angular.

Having seen what a bit of functional programming did to JavaScript, I started looking into Clojure, the most popular functional language at the time, and into ClojureScript frontend frameworks: reagent, quiescent, om, om.next, and re-frame. Now there's also fulcro that wasn't around back then.

What stood out was how they all handled application state.

They all had developed at least three globally isolated layers:

// obvious pseudocode
async function renderApp() {
while (await waitForChanges()) {
// gather the input (check the url and call APIs)
const input = await getInputFromURLAndAPIs(window)
// normalize that to an intermediary app state
const appState = convertInputToApplicationState(input)
// create HTML out of that intermediary state
const html = convertApplicationStateToHTML(appState)

render(html)
}
}

No framework skipped the application state step. Nobody did this:

async function renderApp() {
while (await waitForChanges()) {
// gather the input (check the url and call APIs)
const input = await getInputFromURLAndAPIs(window)
// create HTML out of that input
const html = convertInputToHTML(input)
render(html)
}
}

Yet that's exactly what you're doing when you store application state in React components.

The three layersโ€‹

All these frameworks reached the conclusion that the best way to convert API responses to DOM nodes was to first convert them to an intermediary representation: the global application state.

These three layers (input, data and view) have overlapping, yet separate and parallel hierarchies:

Kea TypeScript ResetContext

The structure of your input data is different from the structure of your application state, which is different from the structure of your DOM nodes.

The folks at Facebook/React agree: to build great user experiences, you benefit from having parallel data and view trees. They do use Relay after all for their state.

Yet the work with Concurrent Mode, Suspense, useTransition, startTransition, useDeferredValue, the suggestion to start fetching early in event callbacks, etc, is moving React in a direction where it's taking on more and more responsibilities from the data layer.

I think this is a clear step backwards for maintainability.

Wild speculation

This is pure speculation, but I think this is why Suspense is still considered experimental in 2021, despite having been in development for over 3 years. It's just not that easy to blend the data and view layers in a way that makes everyone happy. Hence things keep being pushed til "when it's done".

Put your data firstโ€‹

I think it's time for a change. It's time for a paradigm shift.

It's time for a revolution in data-first frontend frameworks, which relegate React to what it does best: rendering and diffing DOM nodes. Values in, actions out. No useState. No useEffect.

In practice this means adopting a data layer as the core of your application.

It means going from:

Frontend > React > [state management library of choice]

to

Frontend > Data Layer > View Layer (React)

Rob Pike puts it best:

Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming.

In all my years of programming I have yet to see an exception to this rule. On the contrary, teaching this to junior developers has proven to be the single most impactful thing in improving their code quality.

Forget JSX templates. Forget algorithms. Get your data structures right, and the rest will follow:

From that moment forward, I would see this everywhere. If ever I felt like my program was getting too complicated or hard to read, it was almost always a data structure problem. Since then, every time I've been humbled by another programmer's code, it hasn't been by clever tricks or algorithms. It has always been by their ingenious insight into how their program's data ought to be organized.

It's time for a change. It's time for a paradigm shift. It's time for a revolution in data-first frontend frameworks that control the view layer and not the other way around.

Since you're reading this on the Kea blog, I'm obviously biased as to what's the best data layer for frontend developers. Suggesting alternatives is made even more complicated by the fact that most other tools position themselves as "state management" libraries, at the mercy of React.

Kea is one of the few frameworks for managing the complete lifecycle of your data. It uses React as the view layer and integrates nicely with the existing Redux ecosystem.

Go check it out and then start writing webapps the way they were meant to be written: data first.

Read the comments to this post on HN

ยท One min read
Marius Andra

What's that? A new release?!?

Kea 2.4 brings the following:

  • Fixed crashes with React Fast Refresh.

  • Changed the default path for logic without a path (or when not using the kea babel plugin) from kea.inline.2 to kea.logic.2. If you have ever hardcoded "kea.inline" anywhere, perhaps in tests, this will cause a bit of headache. If you need it set at kea.inline, use: resetContext({ defaultPath: ['kea', 'inline'] }).

  • Added <Provider /> tag to simplify calling React-Redux's <Provider store={getContext().store} />.

Oh, and there's a new Kea CRA template you can use.

Starting a new CRA project with all the Kea tools configured is now as simple as running:

yarn create react-app my-kea-app --template kea-typescript
cd my-kea-app
yarn start

... and you're good to go!

ยท One min read
Marius Andra

TypeScript support in Kea just got a boost! Starting with kea-typegen 0.7.0 we also:

  • Automatically add import { logicType } from './logicType' in logic files.
  • Automatically add the logicType type to kea<logicType>().
  • Separate changed files in the log with ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ!
Auto Import Logic Type

ยท 2 min read
Marius Andra
TL;DR

New tag: <BindLogic logic={itemLogic} props={{ id: 12 }}>. It lets you write useValues(itemLogic) instead of useValues(itemLogic({ id: 12 })) in nested children. Powered by React Context.

When using keyed logics it's easy to get into a situation where you need to pass props similar to this:

import { kea, useValues } from 'kea'

const itemLogic = kea({ key: (props) => props.id /* ... */ })

function List() {
return (
<>
<Item id={12} />
<Item id={15} />
</>
)
}

function Item({ id }) {
const { className } = useValues(itemLogic({ id }))

return (
<div className={className}>
<Title id={id} />
<Body id={id} />
</div>
)
}

function Title({ id }) {
const { title, url } = useValues(itemLogic({ id }))
return <a href={url}>{title}</a>
}

function Body({ id }) {
const { body } = useValues(itemLogic({ id }))
return <div>{body}</div>
}

In Kea 2.3 you can use <BindLogic> to clear this up:

import { kea, useValues, BindLogic } from 'kea'

const itemLogic = kea({ key: (props) => props.id /* ... */ })

function List() {
return (
<>
<BindLogic logic={itemLogic} props={{ id: 12 }}>
<Item />
</BindLogic>
<BindLogic logic={itemLogic} props={{ id: 15 }}>
<Item />
</BindLogic>
</>
)
}

function Item() {
const { className } = useValues(itemLogic)

return (
<div className={className}>
<Title />
<Body />
</div>
)
}

function Title() {
const { title, url } = useValues(itemLogic)
return <a href={url}>{title}</a>
}

function Body() {
const { body } = useValues(itemLogic)
return <div>{body}</div>
}

When you write <BindLogic logic={itemLogic} props={{ id: 12 }}>, Kea stores a specific build of itemLogic inside a React Context. Any nested children will get that when calling e.g: useValues(itemLogic).

ยท 11 min read
Marius Andra

Even before Kea reached 1.0 last year, one topic kept popping up over and over again:

"Yeah it's great, but what about typescript?"

... or more eloquently:

"Unless the API has changed dramatically in the last few months itโ€™s written in a way that ensure that itโ€™s basically impossible to create effective typescript types to use it safely."

While that comment above is still technically true, as of version 2.2 (2.2.0-rc.1), Kea has full support for TypeScript!

The road there was long and winding... with plenty of dragons along the way.

Yet we prevailed!

But how?

What is Kea?

Kea is a state management library for React. Powered by Redux. It's like Redux Toolkit, but different, and older. It's designed to spark joy!

TypeScript Supportโ€‹

First up, it's relatively easy to add TypeScript to a project. Just install the deps, convert your files to .ts or .tsx, set compilerOptions in tsconfig.json to strict and add types until there aren't any anys left.

This already gives a lot!

For example an autocomplete for resetContext:

Kea TypeScript ResetContext

But we want more. This should work as well:

Kea TypeScript No Values

and this:

Kea TypeScript No Input Listeners

How on earth do we do that?

As predicted by the Redditor quoted above:

"Unless the API has changed dramatically in the last few months itโ€™s written in a way that ensure that itโ€™s basically impossible to create effective typescript types to use it safely."

It turns out code like this is nearly impossible to type safely:

const logic = kea({
// 1.
actions: {
openBlog: (id: number) => ({ id }), // 2.
},
reducers: (logic) => ({
// 3.
blog: [
null,
{
openBlog: (_, { id }) => id, // 4.
[appLogic.actions.closeBlog]: () => null, // 5.
},
],
}),
selectors: {
doubleBlog: [
(s) => [s.blog], // 6.
(blog) => (blog ? blog * 2 : null),
],
tripleBlog: [
(s) => [s.blog, s.doubleBlog], // 7.
(blog, doubleBlog) => blog + doubleBlog,
],
},
})

Who could have guessed?

There's a lot happening here:

  1. We have a lot of keys (actions, reducers) inside one huge object literal {}
  2. We have one action openBlog that takes an (id: number) and returns { id }
  3. The reducers are specified as a function that gets the logic itself as its first parameter. That's some TS-killing loopy stuff right there!
  4. The reducer blog uses the openBlog action (defined above in the same object!) to change its value
  5. This reducer also depends on an action from a different logic, appLogic
  6. The selector doubleBlog depends on the return type of the blog reducer
  7. The selector tripleBlog depends on both blog and doubleBlog and their return types.

These are just a few of the complications.

This was going to be hard.

Yet I was determined to succeed, for I had on my side the strongest motivation on the planet: I had to prove someone wrong on the internet.

Attempt 1โ€‹

It immediately became clear that just getting rid of anys in the codebase wasn't going to be enough.

The JavaScript that converts kea(input) into a logic is just a bit too complicated for the TypeScript compiler to automatically infer types from it.

TypeScript Generics enter the game.

Just write a long TypeScript type that gets the kea(input) parameter's type, looks at its properties and morphs them into a LogicType. Write some functional loopy stuff in a funny markup language. No big deal.

So I thought.

The first attempt looked like this when stripped to its core:

type Input = {
actions?: (logic: Logic<Input>) => any // !
reducers?: (logic: Logic<Input>) => any // !
// ...
}
type Logic<I extends Input> = {
actions: MakeLogicActions<I['actions']>
reducers: MakeLogicReducers<I['reducers']>
// ...
}
function kea<I extends Input>(input: I): Logic<I> {
return realKea(input)
}

// helpers
type MakeLogicActions<InputActions> = {
[K in keyof InputActions]: (
...args: Parameters<InputActions[K]>
) => {
type: string
payload: ReturnType<InputActions[K]>
}
}
type MakeLogicReducers<InputReducers> = {
// skip
}

This implementation gives us type completion when using the logic:

Kea TypeScript Values

... but not when writing it:

Kea TypeScript No Input Listeners

The lines marked // ! are where this breaks down.

There's just no way to make the (logic: Logic<Input>) => any inside Input depend on the I extends Input that was passed to Logic<Input>.

Got all that? Me neither.

This kind of loopy stuff is just not possible with TypeScript:

// don't try this at home
type Input<L extends Logic<Input> = Logic<Input>> = {
actions?: (logic: L) => MakeInputActions[Input['actions']] // ???
reducers?: (logic: L) => MakeInputReducers[Input['actions']] // ???
// ...
}
type Logic<I extends Input<Logic> = Input<Logic>> = {
actions: MakeLogicActions<I['actions']>
reducers: MakeLogicReducers<I['reducers']>
// ...
}
function kea<I extends Input<Logic<I>>>(input: I): Logic<I> {
return realKea(input)
}

With this attempt I got something to work, but ultimately without typing assistance inside the logic, it wouldn't prove someone on the internet wrong enough.

Back to the drawing board!

Attempt 2โ€‹

I first alluded to automatic type generation 10 months ago, yet it always seemed like a huge undertaking. There had to be an easier way.

What if I changed the syntax of Kea itself to something friendlier to TypeScript? Hopefully in a completely opt-in and 100% backwards-compatible way?

Surely there won't be any problems maintaining two parallel implementations and everyone using Kea will understand that this is Kea's hooks moment [1], right? Right?

Right?

Would something like this be easier to type?

// pseudocode!
const logic = typedKea()
.actions({
submit: (id) => ({ id }),
change: (id) => ({ id }),
})
.reducers({
isSubmitting: [false, { submit: () => true }],
})
.listeners(({ actions }) => ({
// (logic) => ...
submit: async ({ id }) => {
actions.change(id)
},
}))

I mean, it's just a slight alteration to this code that already works:

// real code!
const logic = kea({})
.extend({
actions: {
submit: (id) => ({ id }),
change: (id) => ({ id }),
},
})
.extend({
reducers: {
isSubmitting: [false, { submit: () => true }],
},
})
.extend({
listeners: ({ actions }) => ({
submit: async ({ id }) => {
actions.change(id)
},
}),
})

Surely not a big effort to refactor?

Unfortunately (or fortunately?), this approach didn't work either.

While this huge chain of type extensions sounds good in theory, you'll hit TypeScript's max instantiation depth limit eventually, as discovered by someone who was trying to add TS support to ... ehm, SQL?

I would experience the same. After a certain complexity the types just stopped working.

Definitely not ideal... and again won't prove someone on the internet wrong enough.

Attempt 3โ€‹

Attempt 3 was one more go at attempt 1, but by building out the types in the other direction:

So instead of:

type Input = {
actions?: (logic: Logic<Input>) => any
reducers?: (logic: Logic<Input>) => any
}
type Logic<I extends Input> = {
actions: MakeLogicActions<I['actions']>
reducers: MakeLogicReducers<I['reducers']>
}
function kea<I extends Input>(input: I): Logic<I> {
return realKea(input)
}

I started with something like:

interface AnyInput {}
export interface Input<A extends InputActions, R extends InputReducers, L extends InputListeners> extends AnyInput {
actions?: InputActions
reducers?: InputReducers<A, ReturnType<R>>
listeners?: () => InputListeners<A, ReturnType<R>>
}

export interface Logic<I extends AnyInput> {
/* This is a problem for another day! */
}

export declare function kea<T extends Input<T['actions'], T['reducers'], T['listeners']>>(input: T): Logic<T>

... only to fail even harder.

Attempt N+1โ€‹

There were many other experiments and types that I tried.

They all had their issues.

In the end, it appears that this kind of loopy syntax that Kea uses together with selectors that depend on each other just wouldn't work with TypeScript.

That's even before you take into account plugins and logic.extend(moreInput).

What now?โ€‹

I guess there's only one thing left to do.

My job now is to spend countless nights and weekends building kea-typegen, which will use the TypeScript Compiler API to load your project, analyse the generated AST, infer the correct types and write them back to disk in the form of logicType.ts files.

These logicTypes will then be fed back to the const logic = kea<logicType>() calls... and presto! Fully typed logic!

It's not ideal (ugh, another command to run), but it should work.

The stakes are high: If I fail or quit, the person on the internet will be proven right... and that is just not an option.

Automatic Type Generationโ€‹

Thus it's with great excitement that I can announce kea-typegen to the world!

It's still rough with a lot of things to improve, yet it's already really useful!

We've been using it in PostHog for about a week now, and it's working great!

Take that, random person on the internet!

Install the typescript and kea-typegen packages, run kea-typegen watch and code away!

Read the TypeScript guide for more details.

Kea-TypeGen

Rough Edgesโ€‹

This is the very first version of kea-typegen, so there are still some rough edges.

  1. You must manually import the logicType and insert it into your logic. This will be done automatically in the future.
Import Logic Type Manually
  1. You must manually hook up all type dependencies by adding them on the logicType in logic.ts. Kea-TypeGen will then put the same list inside logicType. This will also be done automatically in the future.
Send Type to Logic Type
  1. When connecting logic together, you must use [otherLogic.actionTypes.doSomething] instead of [otherLogic.actions.doSomething]
Use ActionTypes
  1. Sometimes you might need to "Reload All Files" in your editor at times... or explicitly open logicType.ts to see the changes.

  2. Plugins aren't supported yet. I've hardcoded a few of them (loaders, router, window-values) into the typegen library, yet that's not a long term solution.

  3. logic.extend() doesn't work yet.

These are all solvable issues. Let me know which ones to prioritise!

Alternative: MakeLogicType<V, A, P>โ€‹

At the end of the day, Kea's loopy syntax doesn't bode well with TypeScript and we are forced to make our own logicTypes and feed them to Kea.

However nothing says these types need to be explicitly made by kea-typegen. You could easily make them by hand. Follow the example and adapt as needed!

To help with the most common cases, Kea 2.2.0 comes with a special type:

import { MakeLogicType } from 'kea'

type MyLogicType = MakeLogicType<Values, Actions, Props>

Pass it a bunch of interfaces denoting your logic's values, actions and props... and you'll get a close-enough approximation of the generated logic.

interface Values {
id: number
created_at: string
name: string
pinned: boolean
}

interface Actions {
setName: (name: string) => { name: string }
}

interface Props {
id: number
}

type RandomLogicType = MakeLogicType<Values, Actions, Props>

const randomLogic = kea<RandomLogicType>({
/* skipping for brevity */
})

The result is a fully typed experience:

MakeLogicType

You'll even get completion when coding the logic:

MakeLogicType Reducers

Thank you to the team at Elastic for inspiring this approach!

Closing wordsโ€‹

TypeScript support for Kea is finally here!

Well, almost. You can already use it in Kea v2.2.0-rc.1. The final v2.2.0 is not far away.

I've been building kea-typegen in isolation until now. I'd love to hear what the wider community thinks of it. Is it useful? What's missing? How can I improve the developer ergonomics? Can it work in your toolchain? Should I send the created logicTypes to GPT-3, so it would code the rest of your app? And who ate all the bagels?

Just open an issue and let's chat!

Also check out the samples folder in the kea-typegen repository for a few random examples of generated logic.

Finally here's a 12min video where I add TypeScript support to PostHog (we're hiring!):

Footnotesโ€‹

[1] Hooks Moment: A massive improvement in developer ergonomics at the cost of all old code becoming legacy overnight.