Kea 3.0 – State Management via Logic Builders
keajs.org
keajs.org
In my experience you need some amount of boilerplate, or what I'd call _architecture_, to have a stable system. Give too much freedom ("just use get/set" or "variables on context"), and not only can no other developer on your team not maintain your code, but no part of your app will work together after a while. You'll end up with multiple incompatible implementations of ad-hoc state machines.
The key to maintainability is to use a framework that forces you to do spend a bit more effort than you'd do when coding YOLO, but which forces everyone on the team to make the same tradeoff. The result: everyone can jump in to any part of the project and with a bit of digging around be fluent in the surroundings.
Kea seems to strike a pretty good balance of making this possible in large projects.
Here's one of the examples from the docs (https://keajs.org/docs/core/listeners/#error-handling)
kea([
actions({
loadUsers: true,
loadUsersSuccess: (users) => ({ users }),
loadUsersFailure: (error) => ({ error }),
}),
reducers({
users: [
[],
{
loadUsersSuccess: (_, { users }) => users,
},
],
usersLoading: [
false,
{
loadUsers: () => true,
loadUsersSuccess: () => false,
loadUsersFailure: () => false,
},
],
usersError: [
null,
{
loadUsers: () => null,
loadUsersFailure: (_, { error }) => error,
},
],
}),
listeners(({ actions }) => ({
loadUsers: async () => {
try {
const users = await api.get('users')
actions.loadUsersSuccess(users)
} catch (error) {
actions.loadUsersFailure(error.message)
}
},
})),
])
I argue that an API with a different shape could implement the same behavior in a more direct readable style, without giving up any of the nice debugging, etc that you get with Kea's abstractions. class Users extends OtherKeaAPI {
users = this.state<User[]>([])
usersLoading = this.state(false)
usersError = this.state<Error | null>(null)
loadUsers = this.action(action => {
this.usersLoading = true
this.usersError = null
action.effect('load em up', async () => {
try {
this.loadUsersSuccess(await api.get('users'))
} catch (error) {
this.loadUsersFailure(error)
}
})
})
loadUsersSuccess = this.action((users: User[]) => {
this.usersLoading = false
this.users = users
})
loadUsersFailure = this.action((error: Error) => {
this.usersLoading = false
this.usersError = error
})
}In this case I would argue that the end goal is "maintainability", not "readability", and Kea, thanks to forcing you to use an abstraction in the first place, leads to better maintainable code.
Piping all "writes" through actions, and all "reads" through selectors gives you complete observability over the entire system at any point in time. The immutable nature of this approach also works wonders in preventing React from wasting time with needless re-renders. Plus the syntax makes it that you can jump into any part of a system consisting of 200+ logic files, and immediately understand how each piece of data came about, and what are the only things that can change it.
Using "simple" and "readable" plain functions that go `this.something = "value"` is the antithesis of a maintainable system. You lose all observability, unless you integrate a lot of "magic", for example making every value secretly use "immer".
> There is a lot of non-linear control flow encoded implicitly in calls to `kea`, `actions`, `listeners` in this framework, and for what?
To build large applications that scale well, are easy to reason about, and can be maintained by whoever joins your team.
Oh - of course. But I think Kea’s current syntax is also a kind of magic; but a different sort of magic making different tradeoffs. For example - the typescript typedef generation build step magic is a consequence of the current syntax. At the end of the day, a developer debugging any system that uses a framework may need to understand it’s internals to debug the issue. Reducing magic (or containing magic to a thin part of the framework) makes that step easier.
The kea internals will generate action objects from the `actions` call, and the `reducers` call provides a shorthand to responding to those actions. Listeners provides a way to spawn effects when an action occurs. These are compositions and abstractions over the Redux core. To figure out how they map to Redux, you need to either read the docs, or read the source.
I think you can map the example class I wrote to similar concepts - but instead of three+ different function calls to build a logic, you could do one, like `magical(classInstance)` - which would scan the instance and derive internal state machinery much the same way that kea’s `actions` and `reducers` iterate over their argument objects. Action properties translate to actions, state properties translate to reducers, and although listeners aren’t explicitly scannable, the effect callback inside an action can provide a similar API w/ breakpoint() within that scope.
Make the state properties only assignable inside an action function interior, and store their data inside Redux. Buffer writes to these properties until the end of the action internal function, and then flush them as a single Redux reducer change. Data mutation is bad - I agree! Deep-freeze the data those state properties point to in local development mode, and use a proxy that throws on invalid mutations like classInstance.users.push(…) to guide developers —- or use Immer as you suggest.
Dispatch calls to the action methods as Redux actions of the form { target: classInstance, action: methodName, payload: methodArgs }, and also build a reducer that will call the action function when the reducer receives that Redux action object.
This api is certainly more magical that kea’s api — but it’s still a composition on top of Redux. It has even more ”shorthand” for Redux. But would it be less maintainable? No matter the framework - Redux, Redux Toolkit, Kea, this monstrosity I just cooked up - your team needs to invest in culture and training about the right way to do things. There’s nothing AFAICT that kea-the-framework-code or redux-the-framework-code can do to prevent a developer from doing wild side-effects inside a reducer; likewise the sketch above needs culture and linters that guide developers to always wrap effects in action.effect, don’t use untracked private state, etc. Maybe the kea API structure makes it easier to build the cultural parts, but requires more boilerplate - that’s part of the trade off.
I guess the question I’m asking is, why is kea’s current position on the explicit-vs-magic spectrum the right global maximum for developer ergonomics & maintainability? kea advanced over raw Redux already, but why not go even further?
> I think you can map the example class I wrote to similar concepts - but instead of three+ different function calls to build a logic, you could do one, like `magical(classInstance)` - which would scan the instance and derive internal state machinery much the same way that kea’s `actions` and `reducers` iterate over their argument objects.
That's the magic of Kea: it gives you a framework you can use to get exactly the DSL that makes sense for your app.
This example code you posted:
kea([
actions({
loadUsers: true,
loadUsersSuccess: (users) => ({ users }),
loadUsersFailure: (error) => ({ error }),
}),
reducers({
users: [
[],
{
loadUsersSuccess: (_, { users }) => users,
},
],
usersLoading: [
false,
{
loadUsers: () => true,
loadUsersSuccess: () => false,
loadUsersFailure: () => false,
},
],
usersError: [
null,
{
loadUsers: () => null,
loadUsersFailure: (_, { error }) => error,
},
],
}),
listeners(({ actions }) => ({
loadUsers: async () => {
try {
const users = await api.get('users')
actions.loadUsersSuccess(users)
} catch (error) {
actions.loadUsersFailure(error.message)
}
},
})),
])
I'd personally write as this code: kea([
loaders({
users: { loadUsers: () => api.get('users') }
})
])
and it would do exactly the same thing.Well, almost, since the `usersError` message won't be saved. To get that you will need to add a reducer.
kea([
loaders({
users: { loadUsers: () => api.get('users') }
}),
reducers({
usersError: (_, { loadUsersFailure: (_, { error }) => error })
}),
])
This is the magic Kea brings to the table, as you can use the core building blocks as frontend state management lego, creating an ever higher language with which to describe your application's logic.Loading data is such a common pattern, that it makes sense to standardise on a framework level. Who knows what needs your app will have.
> But would it be less maintainable? No matter the framework - Redux, Redux Toolkit, Kea, this monstrosity I just cooked up - your team needs to invest in culture and training about the right way to do things.
You're hitting the nail on the head. I've worked with many developers using Kea over the last years, and while people get it quite quickly, you do need to actually read the docs to understand how it all works together. Otherwise you'll bring your old patterns to this new environment, and write bad (hard to maintain) code.
But this is the case no matter the framework, as you argue. If you don't put in the time in to learn the tooling, your output will be highly inefficient.
At work, I've spent quite a bit of time teaching colleagues how to write Kea the right way, and will now contribute most of this knowledge back in the form of screencasts and tutorials on keajs.org. My hope is that these will help future colleagues of mine level up faster, and the same would apply to all other teams using Kea.
> I guess the question I’m asking is, why is kea’s current position on the explicit-vs-magic spectrum the right global maximum for developer ergonomics & maintainability? kea advanced over raw Redux already, but why not go even further?
You need to draw lines somewhere, and I believe "base 3" (actions, reducers, selectors) is a good place for that. This is in contrast to "base 2" (get, set), which lures with its simplicity, but lead to abstractions that perform worse when you chunk concepts. That extra unit of reasoning within the core abstraction gives a lot. Think 3*3*3=27 vs 2*2*2*2=16.
I get this is going very philosophical/abstract, but we're discussing "magic", so I think we're good :D.
Having seen a lot of different ways to code frontend over the years, I believe a redux-like pattern is the best fit for frontend apps, all* things considered. Compared to "base 2" (get/set) through regular JS primitives, the big difference is that redux forces you to label each write operation with an action type. Every write. This enables a whole class of side effects and observability. These inherent benefits of actions can then be described as magic that comes for free with the abstraction, not as magic that comes from building clever code that hides a complex implementations. As a developer, the former is so much easier to truly and intuitively master, while the latter sounds like a bag of edge cases that you'll keep poking with a stick (see: apollo, based on what I've read on HN lately).
Add to that a global immutable state tree that you can just pass around in a variable (the store), a system of labelled data retrievers from this state tree (selectors), and a way to connect all of these pipes directly to React, an auxiliary system of props and keys to fork data and its operations, a standard library of utils for common operations (routing, forms), commitment to compact syntax, a great way to compose code, and an ever expanding set of learning materials.
The result is what is Kea now. I'm not forcing anyone to use it, but I do think it strikes very close to the global maximum of developer ergonomics and maintainability.
I'm always happy to improve it, and Kea 3.0 was an exercise to push that envelope even further.
- https://redux.js.org/tutorials/essentials/part-2-app-structu...
- https://redux.js.org/introduction/why-rtk-is-redux-today
- https://redux.js.org/tutorials/typescript-quick-start
I'm also interested in hearing any particular features in Kea you find useful that aren't covered in RTK.
Had Redux Toolkit been around when I started with Redux in 2016, I probably wouldn't have needed to write Kea. However I'm glad I did, as I now do consider Kea to be the better toolkit for the same job. :D. I know you disagree, and that's all fine of course :).
In my mind Kea fills in all the missing parts from React, while Redux Toolkit is still a set of utility functions for state management. You still need to find another set of tools for data loading (I see this has improved), complex data or form management, app routing, scene handling, etc. Redux Toolkit doesn't bother with most of that, and I don't think it should.
Kea however comes with solutions for all of those. The only thing missing from being able to build a complete app is a JS module bundler. Just sit down and start writing your actual app with the least amount of syntax possible to explain things in a redux'y way. Many people have told me that with Kea, the framework basically gets out of the way and disappears.
Now with the Kea 3.0 Logic Builder syntax, Kea is suddenly like lego for state management. I'm curious to see if this picks up or not. I can't unfortunately link Kea from redux.js.org to drive traffic :).
[1] https://github.com/PostHog/posthog/issues/301 -- the magic of working in an open source company is that we can post stuff like this. We're hiring!
I know I've said this in a couple issue threads, but just to re-state it publicly:
As Redux maintainer and RTK creator, I do feel RTK is the best approach for using Redux, and that's why we now teach it as default.
But it's great that you've been able to build out an alternate toolset on top of the Redux core that suits your needs and share that, and it's _also_ my job as maintainer to make sure that you still have the tools available to keep that working. (which is why even though I've marked `createStore` as deprecated per our prior issue threads, we're not going to _remove_ it - too many apps and libs depend on it as-is.)
And yeah, agreed that Kea and RTK have different areas of focus. We don't _want_ to handle routing or forms :)
First, it's 2 rewrites. Second, those were all 98% backwards compatible for the users (might need to upgrade plugins), and just refactored the internals.
But sure, let's all write perfect code on day 0, taking into account all the ways libraries you depend on (React) can develop in the future. It's not like "frontend" is a moving target or anything...
/s