| name | refrakt |
| description | How to use Refrakt signals-based state management library |
Refrakt is a lightweight state management library built on TC39 signals.
Modules
refrakt — curated entry point: re-exports the primary constructors and core types
refrakt/signal.js — signal primitives: signal, computed, effect, untrack
refrakt/reducer.js — pure reducer-based state: reducer, withLogging
refrakt/store.js — transactional state with effects: store, tx, noFx, withLogging
refrakt/send.js — action utilities: forward
refrakt/scope.js — scoped child stores: scope
refrakt/iter.js — async iterator utilities: mergeAsync, sequenceAsync, mapAsync
refrakt/never.js — exhaustiveness helpers: assertNever, NeverError
Reducer (pure state, no effects)
Use reducer when you only need state transitions with no side effects.
import { type Reducer, reducer } from "refrakt/reducer.js";
import { assertNever } from "refrakt/never.js";
type Action = { type: "increment" } | { type: "decrement" };
const update: Reducer<number, Action> = (state, action) => {
switch (action.type) {
case "increment":
return state + 1;
case "decrement":
return state - 1;
default:
return assertNever(action);
}
};
const counter = reducer(update, 0);
counter.send({ type: "increment" });
counter.get();
Store (state + effects)
Use store when you need side effects. The update function returns a transaction via tx(state, fx?). Effects are async generators that yield actions back to the store. The state() callback passed to fx reads current store state — use it to check flags and cancel effects.
import { type Update, store, tx } from "refrakt/store.js";
import { assertNever } from "refrakt/never.js";
type Model = { isRunning: boolean; elapsed: number };
type Action = { type: "start" } | { type: "stop" } | { type: "tick" };
const update: Update<Model, Action, void> = (state, action) => {
switch (action.type) {
case "start":
return tx(
{ ...state, isRunning: true } as Model,
async function* (state) {
while (state().isRunning) {
await delay(1000);
if (state().isRunning) {
yield { type: "tick" };
}
}
},
);
case "stop":
return tx({ ...state, isRunning: false } as Model);
case "tick":
return tx({ ...state, elapsed: state.elapsed + 1 } as Model);
default:
return assertNever(action);
}
};
const myStore = store(update, { isRunning: false, elapsed: 0 });
Store with context
Pass a third argument to store for dependency injection:
const update: Update<Model, Action, { api: Api }> = (
state,
action,
context,
) => {
switch (action.type) {
case "fetch":
return tx({ ...state, loading: true } as Model, async function* () {
const data = await context.api.getData();
yield { type: "set", data };
});
}
};
const myStore = store(update, initialState, { api: myApi });
Signals
import { signal, computed, effect, untrack } from "refrakt/signal.js";
const count = signal(0);
const doubled = computed(() => count.get() * 2);
const cleanup = effect(() => {
console.log(count.get());
});
const value = untrack(() => count.get());
Scope
Create a child store that projects a subset of parent state and maps actions:
import { scope } from "refrakt/scope.js";
const child = scope({
store: parentStore,
get: (state) => state.child,
tag: (action) => ({ type: "child", action }),
});
Forward
Transform a send function to tag actions:
import { forward } from "refrakt/send.js";
const sendChild = forward(parentStore.send, (action) => ({
type: "child",
action,
}));
Key patterns
- Exhaustive switches: Use
assertNever(action) (from refrakt/never.js) in the default arm. TypeScript will error if the switch isn't exhaustive.
- Cancelling effects: Use
state() inside fx to read current state. Check a flag (e.g. isRunning) to break out of loops.
- Atomic check-then-update: The update function sees state at the moment of dispatch, preventing race conditions.
as Model assertions: Use as Model when spreading state in tx() calls to help TypeScript infer the transaction type.