Repository navigation
feat!: Transactional effects - #3
Merged
Merged
Conversation
Create plain reducer at reducer.js
- Remove middleware - Remove pipe - Add logging helpers for update fns - Factor out scope - Factor out shared types
And run prettier
This distinguishes between Signal.effect() and Fx.
gordonbrander
marked this pull request as ready for review
May 21, 2026 10:58
Also throw a NeverError, carrying value for debugging purposes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR significantly refactors the library, breaking store into two concepts:
reducer(): a vanilla state reducerstore()a store with transactional managed side-effects (like Elm)This is a significant breaking change in the API surface, and will merit a major version bump.
Changes
Breaking changes:
reducer(nee store)store()with built-in transactional effectsupdateUnknownwithassertNeverand move toutils.jssend.jsscope()changed from middleware to helper function and moved toscope.jspipe()(was only needed for middleware)TaggedActionandaction()(didn't see real world use)Additions:
withLogging()available from bothstore.jsandreducer.js. Decorates update function to add logging.Dev:
Motivation
The core insight is that state updates and side-effects should be issued atomically, in the same transaction. This matters most in flag-then-effect-then-update patterns.
The problem: effects can't participate in state transitions
With separated state and effects (e.g., a reducer +
effect()), you have two fundamentally different systems:This is a structural mismatch. Consider throttling a fetch:
The effect sees that
shouldFetchis true and kicks off a fetch. But how does it setfetching: trueto prevent duplicates? It can't — it's an observer, not a participant in the state transition. It would have to dispatch a separate action to set the flag, which creates a gap between "decided to fetch" and "flag is set." During that gap, another action could arrive, see the flag still unset, and trigger a duplicate.The fundamental issue isn't just timing — it's that the effect doesn't have the authority to atomically couple a state change with the decision to act. The flag-setting and the effect-issuing are in two different systems with two different update mechanisms.
The solution: atomic transactions
In
store(), the reducer returns aTx— a transaction containing both the next state and an optional effect generator, decided together:The reducer is the one place that has full authority over both state and effects. It reads the current state, decides whether to act, sets the flag, and issues the effect — all as a single return value. Looking at the implementation in
store.ts:89-93:sendis synchronous. There is no window between "flag is set" and "effect is issued" where another action could observe intermediate state.Why this matters for throttling
Throttling is the canonical flag-then-effect-then-update pattern:
fetching: true)The guard (
if (state.fetching) return tx(state)) only works if the flag and the effect are set in the same atomic step. With separated state and effects, neither system has enough authority to do this alone — the reducer can set the flag but can't issue effects, and the effect can issue work but can't set the flag. Transactions give a single decision-point full authority over both.This pattern generalizes beyond fetching to any "do this once until done" scenario: debounced saves, optimistic updates with rollback, request deduplication, polling with backoff. In all cases, the invariant is the same — the decision to act and the action itself must be a single atomic step, made by one system with full authority.
Inspired by Elm
This design is borrowed from Elm's
updatefunction, which returns(Model, Cmd Msg)— the next state and commands to execute, together. Every time I try to outsmart Elm, I realize it is I who have been outsmarted. Anyway, Refrakt adapts this to JavaScript's async model using generator functions thatyieldactions back to the store, giving you the same transactional guarantee without leaving the JS ecosystem.