Events solve state management π
Events are the most accurate representation of state. Everything else is a lossy abstraction. LiveStore gets it right β‘οΈ
How is that something as simple as an event can achieve such wonder?
Here are some reasons and use cases for you π
Single entry point
The key (for me) of state management with events is separation of concerns.
Aka, logic is separate from UI β¨
useState
in React is all inside the component. State, actions (functions), implicit events. All of this manual and mostly imperative:
"Copy text" logic useState VS State machine β‘οΈ π useState requires manual timer and state set/reset π State machine tracks the state internally, no need of manual timer nor set/reset β All the logic is outside the component
Events act as a gateway between UI and state logic: UI -> Read state + Send events, how the state changes is an "outside" problem.
Therefore:
- Independent UI (you can implement it in parallel and separate from the logic)
- Reusable logic (not specific to any UI)
- Testable logic, without any rendering
All enabled by a simple abstraction like events ππΌββοΈ
Events as pure functions
The "state manager" receives events and changes the state. That's it (at a high level).
In other words: it's a pure function. Events as input, state as output.
And pure functions are ideal for testing, composability, and local reasoning π€
From setValue(value)
to send({ type: "update", value })
:
// Before
const onUpdate = (value: string) => { setValue(value); }
const onReset = () => { /** ... */ }
const onSubmit = (formData: FormData) => { /** ... */ }
// After
type Events =
| { type: "update", value: string }
| { type: "reset" }
| { type: "submit", formData: FormData };
const send = (input: Events) => { /** ... */ }
A single pure send
function instead of multiple functions and direct updates.
"Pure" is the ideal. In practice, you want some way to handle effects.
In XState for example you use actors π
History with events
Historical context is required for debugging, auditing, or reconstructing past states (undo/redo).
Events have a "single" entry point (i.e. send
) and provide all the information needed to perform an action.
Therefore, you can store a log of them to implement an history β¨
With setValue(value)
you have no way of going back or understanding what happened (the previous value is replaced). Events solve this ππΌββοΈ
Sync
A sync engine aims to converge distributed data to a shared consistent state.
Events unlock this. You have a log for each client, and a sync engine "just" defines how to merge these logs:
type Log = Events[];
const syncEngine = (log1: Log, log2: Log): Log => { /** πͺ */ }
Log
has all the details of everything that happened. As long as the syncEngine
function converges to the same state for all clients, you have your syncing π€
More power, more code
With all these benefits, why not events always and everywhere?
Answer: no reason. Go with events! π«‘
Yes, but with software the correct answer is always "it depends".
Here are some valid reasons when to not use events:
- Performance: direct updates are faster than passing from events
- Reasoning: working with data directly in simple cases avoids the overhead of dealing with events
- Verbosity: setting up events requires more code to read and understand
- Less abstraction: events add another layer of abstraction, not always necessary for simple cases
These lossy representations of state are useful, though; we do it to reason about data, improve performance, and hide irrelevant details. Just don't forget about the event layer. That's where the truth lives.
I am publishing more and more daily content on X on everything effect
, xstate
, and TypeScript:
β¨ TypeScript Tip: Expressive return type β¨ Adding `as const` narrows the inferred return type, making it more expressive If you also add a generic, the return type even becomes a literal π
Next week I fly to Berlin for localfirstconf. Full report coming next week π
See you next π