4 ms·
OP here, so call me biased, but I tend to disagree. Curious to hear your thought on alternative or better approaches to system design here. In my experience yo
by mariusandra 4y ago
OP here, so call me biased, but I tend to disagree. Curious to hear your thought on alternative or better approaches to system design here.
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.
- jitl 4y agoYes - having an architecture that ensures maintainability is critical. But good architecture needn't require this much boilerplate. There is a lot of non-linear control flow encoded implicitly in calls to `kea`, `actions`, `listeners` in this framework, and for what? Here's one of the examples from the docs (https://keajs.org/docs/core/listeners/#error-handling 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 }) }
- mariusandra 4y agoDepends what you mean with "readable". It's always easy to make a contrived example, and call that readable because it's shorter. I'm talking about huge apps maintained by multiple remote teams, all working independently. 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.
- jitl 4y ago> You lose all observability, unless you integrate a lot of "magic", for example making every value secretly use "immer". 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?