I'm currently writing a bus timetable app that uses an object type to model a timetable "document".
interface Timetable {
name: string
stops: string[]
services: string[][]
}
Along with the type I have a number of functions that I would typically write as methods on a class if I were to use mutation. I use Immer primarily so I don't have to write lots of spread syntax. For example,
const addStop = (timetable: Timetable, stopName: string): Timetable => {
return produce(timetable, (newTimetable) => {
newTimetable.stops.push(stopName)
})
}
To manage the state I'm using Zustand and Immer, though I feel my problem would be the same if I was using Redux. In my store I have an array of Timetable objects and then actions that also use Immer to reassign the currently selected timetable object:
updateTt: (tt, index) => {
set((state) => {
state.timetables[index] = tt
})
},
updateThisTt: (timetable) => {
set((s) => {
if (s.selectedTtIdx === null) {
throw new Error("no selected timetable")
}
s.timetables[s.selectedTtIdx] = timetable
})
},
I then call the data changing function inside my React component, and call the updating action:
const onAddStop = (name) => {
updateThisTt(addStop(timetable, name))
}
This works, but I'm not sure I'm doing it right. I now have two layers of Immer calls, my components now have data-modifying functions called directly in their event handlers, and I don't really like how the "methods" look even if, in the grand scheme of things, that is a minor fault.
I've considered:
- Moving all the data modification into actions. This seems like it's going to be less maintainable and harder to understand, with a lot of duplication around indexing the store's array of objects.
- Creating an action for each data-modifying function; then, calling these actions from my event handlers. This seems like it's going to create a bit of duplication, even if just in terms of names.
- Turning my
Timetabletype into a class, rewriting the data-modifying functions as mutating methods, and setting[immerable] = trueand letting Immer do all the work in my actions. I've done this, but I would rather stick to the immutable record pattern.
For what it's worth, documentation for Flux, Zustand or Immer tend to show the first option there, and only occasionally; no app is simple as as counter = counter + 1. With regards to apps out there in the wild using the Flux architecture, what is the best way to structure this?
(I'm not familiar with Zustand and Immer, but maybe I can help ...)
There are always different approaches, I am suggesting my favored one here.
Make a clear distinction between "dispatching" an action, and the actual "mutation" of the state. (Maybe add another level in between).
Specific "mutation" functions
I suggest to create specific "mutation" functions, instead of generic ones, i.e.:
updateThisTt: () => { ...,addStop: () => { ....Create many mutation functions as needed, each serving a single purpose.
Build the new state inside the "mutation" functions
Conceptually, use your
immerproducers only inside the mutation functions.(regarding the store I mean. Of course, you can still use
immerfor other purposes).This seems to be recommended practice as well, according to this official example:
Inside your component you can now just call the "mutator" function.
Building the new state
If building the new state becomes complicated, you can still extract some "builder" functions. But consider the next section "Large files and duplication" first.
E.g. your
addStopfunction could as well be called inside the Zustand mutation:Large files and duplication
Of course code duplication should be avoided, but there are always trade-offs.
I wouldn't suggest a specific way to go, but note that code is usually more read than written. Sometimes I think it is worth writing a few letters more, e.g. something like
state.timetables[ index ]multiple times, if it makes the purpose of the code a lot more obvious. You need to judge that yourself.Anyway, I suggest to put your mutation function into a separate file that does nothing else, this way it probably looks way more understandable than you might think.
If you have a file that is large, but totally focuses only on modifying state, and is consistently structured, then it is pretty easy to read, even if you have to scroll a few pages.
E.g. if it looks like this:
Also note that these mutator functions are completely independent (or are they ? I would hope that Zustand uses pure functions here, isn't it ?).
That means, if it becomes complicated, you might even split multiple mutation functions into multiple separate files inside a folder like
/store/mutators/timeTable.js.But you can do that pretty easy anytime later.
Building "payload" ("action callers")
You might come to a point where you feel the need to have another "level" between event handler and mutation function. I usually do have such a layer, but I don't have a good name for it. Let's call it "action caller" for now.
It's sometimes not easy to decide what belongs inside the "mutation function" and what inside the "action caller".
Anyway, you can "build some data" inside the "action caller", but you should not do any kind of state manipulation here, not even with using
immer.Transform state vs create a payload
That is a subtle distinction, and you probably shouldn't worry about this too much (and there are probably exceptions), but as an example:
You can use some parts of the old state inside the "action caller", like:
But you should not map (or transform) an old state to a new state, like:
Another example
Not making a specific suggestion here, just an example:
Passing many values to a mutator might become confusing, like:
Inside the event handler you want to focus on what you actually want to do, like "add a new item", but not think about all the arguments. Also, you might need that new item for something else.
Ao alternatively you could write: