Skip to content

feat!: Transactional effects - #3

Merged
gordonbrander merged 19 commits into
mainfrom
2026-03-25-combined-fx
May 21, 2026
Merged

gordonbrander merged 19 commits into
mainfrom
2026-03-25-combined-fx

Conversation

@gordonbrander

@gordonbrander gordonbrander commented Mar 25, 2026 •

Copy link
Copy Markdown
Owner

This PR significantly refactors the library, breaking store into two concepts:

  • reducer(): a vanilla state reducer
  • store() 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:

  • Add reducer (nee store)
  • Add store() with built-in transactional effects
  • Replace updateUnknown with assertNever and move to utils.js
  • Shared send-related code moved to send.js
  • Remove middleware as a concept
  • scope() changed from middleware to helper function and moved to scope.js
  • Remove pipe() (was only needed for middleware)
  • Remove TaggedAction and action() (didn't see real world use)

Additions:

  • withLogging() available from both store.js and reducer.js. Decorates update function to add logging.

Dev:

  • Add linting, formatting, and typechecking checks to workflow and prepublish

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:

  1. The reducer, which can update state but can't issue side-effects.
  2. The effect, which can observe state and perform side-effects but can't atomically update state.

This is a structural mismatch. Consider throttling a fetch:

// Separated state + effects — structurally broken for throttling
const state = reducer(update, initial);

effect(() => {
  if (state.get().shouldFetch) {
    fetchData().then(data => state.send({ type: 'complete', data }));
  }
});

The effect sees that shouldFetch is true and kicks off a fetch. But how does it set fetching: true to 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 a Tx — a transaction containing both the next state and an optional effect generator, decided together:

case 'fetch':
  if (state.fetching) {
    return tx(state); // Already in-flight, no-op
  }
  // Flag and effect are a single atomic decision
  return tx(
    { ...state, fetching: true },
    async function* () {
      const data = await fetchData();
      yield { type: 'fetch-complete', value: data };
    }
  );

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:

const send = (action: Action) => {
  const { state, fx } = update($state.get(), action, context!);
  $state.set(state);
  forkFx(fx);
};

send is 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:

  1. Flag: Mark that work is in-flight (fetching: true)
  2. Effect: Do the async work (fetch, timer, etc.)
  3. Update: When the effect completes, clear the flag and apply the result

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 update function, 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 that yield actions back to the store, giving you the same transactional guarantee without leaving the JS ecosystem.

@gordonbrander
gordonbrander marked this pull request as ready for review May 21, 2026 10:58
Also throw a NeverError, carrying value for debugging purposes.
@gordonbrander
gordonbrander merged commit cb92665 into main May 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant