TypeScript Support in Kea (The Long Road)

Marius Andra

Marius Andra

Kea Core Team

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?

The Gallery of Failed Attempts

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.

Kea 2.1: Less squggly bits 🐛 and previous state in listeners 🦜🦜

Marius Andra

Marius Andra

Kea Core Team

Kea 2.1 does two things:

  • Continues the "let's make things simpler" trend started in Kea 2.0 by removing another bunch of squiggly bits that you will not need to type again: " ((((((()))))))===>>>{}"
  • Adds support for accessing the state before an action was fired inside listeners.

It's also backwards compatible: Logic written for Kea version 1.0 will still run in 2.1.

The saga until now:

This is Kea 1.0:

const logic = kea({
actions: () => ({
goUp: true,
goDown: true,
setFloor: floor => ({ floor })
}),
reducers: ({ actions }) => ({
floor: [1, {
[actions.goUp]: state => state + 1,
[actions.goDown]: state => state - 1,
[actions.setFloor]: (_, { floor }) => floor
}]
}),
selectors: ({ selectors }) => ({
systemState: [
() => [selectors.floor],
floor => floor < 1 || floor > 20 ? 'broken' : 'working'
]
}),
listeners: ({ actions, values }) => ({
[actions.setFloor]: ({ floor }) => {
console.log('set floor to:', floor)
if (values.systemState === 'broken') {
console.log('you broke the system!')
}
}
})
})

In Kea 2.0 we can skip [actions.] and { actions }:

const logic = kea({
actions: () => ({
goUp: true,
goDown: true,
setFloor: floor => ({ floor })
}),
reducers: () => ({ // removed "{ actions }"
floor: [1, {
goUp: state => state + 1, // removed "[actions.]"
goDown: state => state - 1, // removed "[actions.]"
setFloor: (_, { floor }) => floor // removed "[actions.]"
}]
}),
selectors: ({ selectors }) => ({
systemState: [
() => [selectors.floor],
floor => floor < 1 || floor > 20 ? 'broken' : 'working'
]
}),
listeners: ({ values }) => ({
setFloor: ({ floor }) => { // changed
console.log('set floor to:', floor)
if (values.systemState === 'broken') {
console.log('you broke the system!')
}
}
})
})

You can still write [actions.] explicitly... and you do it mostly when using actions from another logic:

import { janitorLogic } from './janitorLogic'
const elevatorLogic = kea({
reducers: ({ actions }) => ({
floor: [1, {
goUp: state => state + 1, // local action
[actions.goDown]: state => state - 1, // no longer useful
[janitorLogic.actions.setFloor]: (_, { floor }) => floor
}]
}),
})

... but you save 41 keystrokes in the default case:

"{ actions }[actions.][actions.][actions.]" // byebye

Changed in Kea 2.1:

Why stop there?

There's another low hanging fruit we can eliminate: () => ({}).

Gone!

const logic = kea({
actions: { // removed "() => ("
goUp: true,
goDown: true,
setFloor: floor => ({ floor })
}, // removed ")"
reducers: { // removed "() => ("
floor: [1, {
goUp: state => state + 1,
goDown: state => state - 1,
setFloor: (_, { floor }) => floor
}]
}, // removed ")"
selectors: ({ selectors }) => ({
systemState: [
() => [selectors.floor],
floor => floor < 1 || floor > 20 ? 'broken' : 'working'
]
}),
listeners: ({ values }) => ({
setFloor: ({ floor }) => {
console.log('set floor to:', floor)
if (values.systemState === 'broken') {
console.log('you broke the system!')
}
}
})
})

16 units of squiggly bits gone! Here they are, in chronological and ascending order:

"() => ()() => ()" // chronological
" (((())))==>>" // ascending

They are there if you need them, of course. For example when using props in reducers:

kea({
reducers: ({ props }) => ({
floor: [props.defaultFloor, {
goUp: state => state + 1,
goDown: state => state - 1,
}]
}),
})

What about the selectors? How can we simplify this?

kea({
selectors: ({ selectors }) => ({
systemState: [
() => [selectors.floor],
floor => floor < 1 || floor > 20 ? 'broken' : 'working'
]
})
})

Here's the simplest backwards-compatible change that went into Kea 2.1:

kea({
selectors: { // spot
systemState: [
selectors => [selectors.floor], // the
floor => floor < 1 || floor > 20 ? 'broken' : 'working'
]
} // difference
})

Goodbye another 14 spaces and squgglies:

"({ }) => ()()"

If you're really feeling the minimalist vibe, you could also simplify the object in listeners and events, but:

const elevatorLogic = kea({
listeners: {
setFloor: ({ floor }) => {
console.log('set floor to:', floor)
if (elevatorLogic.values.systemState === 'broken') {
console.log('you broke the system!')
}
}
}
})

You might get tired of writing thisLogic everywhere.

In general, the suggestion is to always write the simplest thing first:

kea({
reducers: {
poteito: // ...
}
})

... and only when needed, extend it into a function to pull in objects and evaluate lazily:

kea({
reducers: ({ props }) => ({
potaato: // ...
})
})

Previous State in Listeners

There's a certain way listeners work:

kea({
actions: {
setFloor: floor => ({ floor })
},
reducers: {
floor: {
setFloor: (_, { floor }) => floor
}
},
listeners: ({ values }) => ({
setFloor: ({ floor }, breakpoint, action) => {
// { floor } = payload of the action
// breakpoint = some cool stuff ;)
// action = the full redux action, in case you need it
console.log("floor in action payload: ", floor)
console.log("floor in state: ", values.floor)
if (floor === values.floor) { // this is true
console.log('the reducer already ran')
console.log('before the listener started')
}
}
}),
)

The action will first update the reducers and only then run the listener.

What if you really need the state before the action ran?

You could set up a two step system (setFloorStart & setFloorUpdate) ... or you could use previousState, the new 4th argument to listeners:

kea({
actions: {
setFloor: floor => ({ floor })
},
reducers: {
floor: {
setFloor: (_, { floor }) => floor
}
},
listeners: ({ selectors, values }) => ({
setFloor: ({ floor }, _, __, previousState) => {
// { floor } = payload
// _ = breakpoint
// __ = action
// previousState = the state of the store before the action
const lastFloor = selectors.floor(previousState)
if (floor < lastFloor) {
console.log('going down!')
}
if (floor > lastFloor) {
console.log('going up!')
}
}
}),
)

Take the store's previousState (new 4th argument) and run it through any selector you can get your hands on. Every value has a selector, so you have plenty to choose from.

How does this work?

This is just another benefit of using Redux under the hood. More specifically, using the idea redux popularised: store everything in one large tree and propagate changes in its branches through cascading immutable updates.

Every unique version of the entire state tree ends up in a plain JS object. This state object is only read from and it will never change... and it's discarded as soon as the next state comes in.

We can still keep a reference to this previous state and use selectors on it to get whichever selected or computed value we need.

Easy as pie!

Mmm... pie. 🍰

4th argument?

Yeah, it's getting busy up there, but 🤷. I'm not going to make a breaking change for this.

New stuff: Gists, WaitFor, Testing

Marius Andra

Marius Andra

Kea Core Team

Few more things got released all at once:

Funny Gists

I added a playground page with three interesting code snippets for Kea:

They are all too rough to be released as official plugins yet, but can already help in some situations.

If you have the time, feel free to contribute new gists or improve on the existing ones!

WaitFor

The kea-waitfor plugin lets you await for actions.

It's great if you're writing tests and want to wait for something to happen before leaving the test

import { kea } from 'kea'
import { waitForAction } from 'kea-waitfor'
const logic = kea({
actions: () => ({
setValue: value => ({ value }),
valueWasSet: value => ({ value })
}),
listeners: ({ actions }) => ({
setValue: async ({ value }) => {
await delay(300)
actions.valueWasSet(value)
}
})
})
logic.mount()
logic.actions.setValue('hamburger')
const { value } = await waitForAction(logic.actions.valueWasSet)
console.log(value)
// --> 'hamburger'

There's also a waitForCondition that lets you ask custom questions from the dispatched actions.

const { value } = await waitForCondition(action => {
return action.type === logic.actions.valueWasSet.toString() &&
action.payload.value === 'cheeseburger'
})

In addition, the plugin documentation includes examples for waiting for different cominations of actions:

  • Wait for all to be dispatched
  • Wait for the first one of many (race)
  • Timeout on the waiting

Testing docs

Inspired by a Github Issue, I wrote a quick doc about unit testing kea logic. Feedback and contributions are very welcome!

Kea 2.0 Released 🦜

Marius Andra

Marius Andra

Kea Core Team
note

New to Kea or saw it last a few years ago? You should take a closer look. A lot has changed and you might like what you see! 😃

8 months after the release of Kea 1.0 I'm proud to announce version 2.0!

This version brings several convenience features. It's a rather small release, yet there were a few breaking changes, which warranted a new major version.

What changed? Read below!

But first! You are reading this blog post in the brand new documentation for Kea! Powered by docusaurus v2! Over 17000 new words were written for these docs in an effort to really clarify how Kea works.

Start with What is Kea? if you're new here. Then head on to the core concepts. Please also read them if you've been using Kea for a while. You might learn something you didn't know! Then check out additional concepts, debugging and other pages for more.

Anyway, where were we?

Oh yes, new stuff in Kea 2.0! 🤩

Listeners built in (1 Breaking Change)

For years Kea has supported two different side effect libraries: sagas and thunks.

With Kea 1.0, I added a new lightweight one called listeners.

Listeners solve the main issue with thunks (you can't use thunks in reducers) and let you write much simpler code than sagas, while retaining the most commonly used features of sagas (debouncing and cancelling workers a'la takeLatest). Unless you're writing highly interactive applications, you will probably not need to use sagas anymore.

Before 2.0 listeners was an optional plugin, but now it's included by default. This enables two big things:

  • Much easier to get started with Kea
  • Plugin authors have a side-effect library that they can always rely on instead of writing bindings for 3 different systems.

Weighing at just 1.4KB (gzipped, 3.4KG minified), including listeners in kea doesn't add a lot of weight.

Yet if you wish to disable them, use skipPlugins when upgrading:

resetContext({ skipPlugins: ['listeners'] })

Breaking change, please note: If you were using listeners with Kea 1.0, make sure to remove listenersPlugin from your resetContext({ plugins: [] }) array or Kea will complain that it's being imported twice.

Writing [actions. and ] is now optional

This used to be the only way to write reducers and listeners:

// Works in all versions of Kea
const logic = kea({
actions: () => ({
increment: (amount) => ({ amount }),
setCounter: (counter) => ({ counter }),
reset: true
}),
reducers: ({ actions }) => ({
counter: [0, {
[actions.increment]: (state, { amount }) => state + amount,
[actions.setCounter]: (_, { counter }) => counter,
[actions.reset]: () => 0
}]
}),
listeners: ({ actions }) => ({
[actions.reset]: () => {
console.log('reset called')
}
})
})

Now you can do this:

// Works with Kea 2.0+
const logic = kea({
actions: () => ({
increment: (amount) => ({ amount }),
setCounter: (counter) => ({ counter }),
reset: true
}),
reducers: () => ({
counter: [0, {
increment: (state, { amount }) => state + amount,
setCounter: (_, { counter }) => counter,
reset: () => 0
}]
}),
listeners: () => ({
reset: () => {
console.log('reset called')
}
})
})

If your actions are defined in the same logic (or imported with connect), you can skip writing [actions. ] and also skip ({ actions }).

Writing [actions.increment] will still work, just like writing [otherLogic.actions.actionName].

This will be especially nice for TypeScript users, who were forced to write [actions.increment as any] to avoid constantly bumping into "error TS2464: A computed property name must be of type 'string', 'number', 'symbol', or 'any'".

Auto-Connect!

Up to Kea 1.0, when you used actions or values from otherLogic inside your logic, you had to connect them together.

import { counterLogic } from './counterLogic'
// Works in all versions of Kea
const logic = kea({
connect: {
// pulling in actions from `counterLogic`
actions: [counterLogic, ['increment', 'decrement']],
// pull in values from `counterLogic`
values: [counterLogic, ['counter']],
},
listeners: ({ actions, values }) => ({
[actions.increment]: () => {
console.log('Increment called!')
console.log(`Counter: ${values.counter}`)
}
})
})

Now you can skip connect (if you want to) and call all actions and values directly on counterLogic:

import { counterLogic } from './counterLogic'
// Works in Kea 2.0+
const logic = kea({
listeners: () => ({
[counterLogic.actions.increment]: () => {
console.log('Increment called!')
console.log(`Counter: ${counterLogic.values.counter}`)
}
})
})

While this also works in Kea 1.0 under some conditions, the code above will always work with Kea 2.0.

In version 1.0 you had to manually assure that counterLogic was mounted before calling actions and values on it. Perhaps it was mounted via useValues in React or alternatively you could also write: connect: { logic: [counterLogic] } without specifying what exactly to connect. The code above would then also work.

In version 2.0 this is no longer necessary. When you:

  • use counterLogic.actions.increment as a key in reducers or listeners
  • use counterLogic.selectors.counter in selectors
  • use counterLogic.anything.really inside a listener

... then counterLogic is automatically connected to logic and mounted/unmounted when needed.

This means the following code will also work:

import { counterLogic } from './counterLogic'
// Works in Kea 2.0+
const logic = kea({
actions: () => ({
showCount: true
}),
listeners: () => ({
showCount: () => {
console.log('Increment called!')
console.log(`Counter: ${counterLogic.values.counter}`)
}
})
})

In this example, the first time you use counterLogic is inside a listener when getting a value from it.

If counterLogic was not already mounted, it will be mounted directly when you call showCount. It will stay mounted for as long as logic is still mounted. It will be unmounted together with logic in case no other mounted logic or component has a lock on it.

There is one caveat with autoConnect for when you want to manually call mount() and unmount() inside a listener. For that please read the section in the Using without React page.

To opt out of autoConnect, pass autoConnect: false to resetContext.

(Optional) Babel plugin to autogenerate paths

If you have ever used the redux devtools, to debug your logic, you will have noticed that unless you specify a path in your logic, it will be automatically placed under kea.inline.[N] like so:

Redux Devtools with Inline Paths

With the new babel-plugin-kea, these paths can be autogenerated from the filesystem, greatly enhancing your debugging experience:

Redux Devtools with Autogenerated Paths

What's more, this can be used in combination with plugins like kea-localstorage or in frameworks like next.js to persist values or hydrate server-rendered logic easier than ever before.

Other smaller improvements

Those were the big ones. A few other things made it into Kea 2.0.

You can extend reducers

Previously in this case:

// Works with Kea 1.0
const logic = kea({
actions: () => ({
doSomething: true,
doSomethingMore: true,
}),
reducers: ({ actions }) => ({
myValue: [0, {
[actions.doSomething]: () => 100
}]
})
})
logic.extend({
reducers: ({ actions }) => ({
myValue: [0, {
[actions.doSomethingMore]: () => 10000
}]
})
})

The entire reducer for myValue would be overridden. This means only the action doSomethingMore would have any effect on the value. This is no longer the case and the reducer mapping is merged when a reducer is extended.

In case of conflicts, later actions override previously defined ones. However the first default value is taken. To override a default, just specify it separately with defaults: { myValue: 100 } within kea({})

In resetContext, createStore is now true by default

Previously when using resetContext and not using any other redux-specific middleware or libraries, you had to write:

// Works with all versions of Kea, but not needed in 2.0
resetContext({
createStore: true // or {}
})

Omitting this createStore: true line would cause Kea to fail. This is no longer necessary. The redux store will be created when you call resetContext without any arguments. Pass false to createStore if you wish to avoid this.

The path in your logic can start with anything

Previously you had to write:

// Works with all versions of Kea, but not needed in 2.0
resetContext({
createStore: {
// defaulted to ['kea', 'scenes']
paths: ['kea', 'scenes', 'pages', 'components']
}
})

... if you wanted your logic.path to start with pages or anything other than kea or scenes. The first part of the path had to be whitelisted.

This is no longer necessary. If you omit paths in createStore, you can use whatever string for the first part of your logic's path.

Specifying paths reverts to whitelisting and anything else is disallowed. Only now it will also throw an error instead of silently just not connecting the logic to redux.

Create a reducer without a default

This used to be the only way to define reducers:

const counterLogic = kea({
actions: () => ({
increment: true,
decrement: true,
}),
reducers: ({ actions }) => ({
counter: [0, { // `0` as default
[actions.increment]: (state) => state + 1,
[actions.decrement]: (state) => state - 1
}]
})
})

Now if you prefer, you can omit the default value in reducers:

const counterLogic = kea({
actions: () => ({
increment: true,
decrement: true,
}),
reducers: () => ({
counter: { // `null` as default if not given in `defaults`
increment: (state) => (state || 0) + 1,
decrement: (state) => (state || 0) - 1
}
})
})

... and either define it in defaults or not at all. It'll just be null if not defined.

Action type string no longer skips scenes.

This is a very minor tweak.

Previously if your action had a path that started with scenes, then it was skipped in the action type toString().

// before
homepageLogic.path == ['scenes', 'homepage', 'index']
homepageLogic.action.reloadPage.toString() === 'reload page (homepage.index)'
accountLogic.path == ['menu', 'account', 'index']
accountLogic.action.reloadAccount.toString() === 'reload account (menu.account.index)'

Now it's included:

// after
homepageLogic.path == ['scenes', 'homepage', 'index']
homepageLogic.action.reloadPage.toString() === 'reload page (scenes.homepage.index)'
accountLogic.path == ['menu', 'account', 'index']
accountLogic.action.reloadAccount.toString() === 'reload account (menu.account.index)'

I told you this was a very minor tweak!

That's it for new stuff in Kea 2.0. Please let me know what is your favourite new feature or if you have anything else to share! 👋

What's next? (Kea 2.1 and/or 3.0)

There are two main things I'd like to explore in the next versions of Kea.

TypeScript support

One of the most requested features for Kea has been proper TypeScript support. While you can get pretty far with Kea in TS if you manually create your interfaces, this is sub-optimal.

The goal for Kea 2.1 (or 3.0?) is to have full and automatic TypeScript support. In fact, many of the changes with 2.0 (namely eliminating the need for connect & no need to write [actions.__]) were done to pave the way.

Even if you don't use TypeScript, this will help IDEs offer proper autocomplete support when writing Kea in regular JavaScript.

Precomplication

At the end of the day, Kea is just an engine that converts input into logic plus a framework to mount/unmount this logic when requested by React components.

What if we could do some of this conversion at compile-time, rather than at runtime?

Now that we have a babel plugin that automatically adds paths to logic, could this be extended to speed up runtime Kea by inlining some of these conversions where possible? Would it make a difference in runtime speed?

Kea's performance has never been an issue so far, but this is an interesting avenue for some exploration.

To be continued.