refrakt
v1.0.0
Published
A lightweight, scalable state management library built on top of TC39 signals
Maintainers
Readme
Refrakt: state management with signals
A lightweight state management library built on top of signals. Pairs well with Lit and other frameworks that support TC39 signals.
Refrakt is built around a simple concept: define a signal with a reducer, send actions to update it.
Features
- Fine-grained reactivity: Built on top of TC39 signals.
- Managed fx: Elm-style transactional fx management.
- Scoped stores: Create child stores that project parent state and tag actions.
- Minimal dependencies: Uses only
signal-polyfilllibrary for maximum compatibility.
Importing
Refrakt is published as ES modules. The package entry point re-exports the primary constructors and core types:
import { signal, computed, effect, reducer, store, tx, scope } from "refrakt";Every module is also available as a subpath import. Reach for these to pull in
long-tail helpers such as withLogging, untrack, noFx, and the iter
async-iterator utilities, which are intentionally kept off the main entry point:
import { signal, computed, effect, untrack } from "refrakt/signal.js";
import { reducer, withLogging } from "refrakt/reducer.js";
import { store, tx, noFx, withLogging } from "refrakt/store.js";
import { scope } from "refrakt/scope.js";
import { forward } from "refrakt/send.js";
import { assertNever } from "refrakt/never.js";
import { mergeAsync, sequenceAsync, mapAsync } from "refrakt/iter.js";The examples below use subpath imports throughout.
Example
Here's a simple counter example using Lit for UI.
import { reducer } from "refrakt/reducer.js";
import { assertNever } from "refrakt/never.js";
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { SignalWatcher } from "@lit-labs/signals";
type Model = { count: number };
type Action = { type: "inc" } | { type: "dec" };
const update = (state: Model, action: Action): Model => {
switch (action.type) {
case "inc":
return { count: state.count + 1 };
case "dec":
return { count: state.count - 1 };
default:
return assertNever(action);
}
};
const counter = reducer(update, { count: 0 });
@customElement("counter-app")
class CounterApp extends SignalWatcher(LitElement) {
render() {
return html`
<div>
<h1>Count: ${counter.get().count}</h1>
<button @click=${() => counter.send({ type: "inc" })}>+</button>
<button @click=${() => counter.send({ type: "dec" })}>-</button>
</div>
`;
}
}Reducer
reducer() creates a signal updated via a pure reducer function. It works like React's useReducer() hook, except it's a signal.
import { reducer } from "refrakt/reducer.js";
import { assertNever } from "refrakt/never.js";
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "set"; value: number };
const update = (state: number, action: CounterAction): number => {
switch (action.type) {
case "increment":
return state + 1;
case "decrement":
return state - 1;
case "set":
return action.value;
default:
return assertNever(action);
}
};
const counterStore = reducer(update, 0);
counterStore.send({ type: "increment" });
console.log(counterStore.get()); // 1Store
store() returns a signal of exactly the same type as reducer(), but with additional support for managed side-effects. Instead of returning just the next state, a store's reducer returns a transaction object containing the next state and optional side-effects. Side-effects are modeled as async generator functions that yield zero or more actions back to the store.
import { store, tx, type Tx } from "refrakt/store.js";
import { assertNever } from "refrakt/never.js";
type Model = { count: number; fetching: boolean };
type Action =
| { type: "increment" }
| { type: "fetch" }
| { type: "fetch-complete"; value: number };
const update = (state: Model, action: Action): Tx<Model, Action> => {
switch (action.type) {
case "increment":
return tx({ ...state, count: state.count + 1 });
case "fetch":
// State update and fx in a single transaction
return tx({ ...state, fetching: true }, async function* () {
const response = await fetch("/api/count");
const data = await response.json();
yield { type: "fetch-complete", value: data.count };
});
case "fetch-complete":
return tx({ ...state, count: action.value, fetching: false });
default:
return assertNever(action);
}
};
const counterStore = store(update, { count: 0, fetching: false });
counterStore.send({ type: "fetch" }); // Sets `fetching` and kicks off fx generator
counterStore.get().fetching; // truetx(state, fx?) offers a convenience function for creating transaction objects. Transactions are just plain objects with state and fx properties:
type Tx<Model, Action> = {
state: Model;
fx: (state: () => Model) => AsyncGenerator<Action>;
};The fx generator function also receives a state() function, which it can use to check on the state of the store after time has elapsed.
async function* (state: () => Model) {
await sleep(2000);
if (state().cancel) {
return;
}
yield { type: "msg", value: "hello world" }
}Transactional side-effects
Why store? Why transactions? For simple side-effects, a signal or reducer combined with effect() may be enough. For example, this gives you the equivalent of React's useEffect():
import { effect } from "refrakt/signal.js";
import { reducer } from "refrakt/reducer.js";
const state = reducer((state: Model, action: Action) => {
// ...
}, initial);
// Fires every time state changes
effect(() => {
service.doSomething(state.get());
});However, when side-effects become sufficiently complex, you may want to reach for a store. The key advantage is that store lets you implement structured and transactional side-effects.
Fx are issued in response to actions during the same transaction as the state. This means you can implement atomic check-then-update-then-fx patterns in response to actions. For example, preventing duplicate fetches:
case 'fetch':
// Already fetching? Do nothing.
if (state.fetching) {
return tx(state);
}
// Set flag AND issue fx atomically
return tx(
{ ...state, fetching: true },
async function* () {
const data = await fetchData();
yield { type: 'fetch-complete', value: data };
}
);Because each update runs sequentially and atomically, and the flag and fx are set during the same transaction, there is no window where a duplicate fetch can slip through. It can be difficult to achieve this kind of atomic control over effects when state and effects are handled separately.
Context
store() accepts an optional third context argument, passed to the update function on every action:
const update: Update<Model, Action, Services> = (state, action, services) => {
// ...
};
const myStore = store(update, initialState, services);This can be used to expose external services to the update function.
Signals
The signals module re-exports the TC39 signals polyfill, as well as providing a handful of convenience functions.
import { signal, computed, effect } from "refrakt/signal.js";
// Create a `State` signal
const count = signal(10);
// Create a `Computed` signal
const doubled = computed(() => count.get() * 2);When you want to react to signal changes, you can use effect. Effects are automatically batched and run on the next microtask, preventing unnecessary re-renders and cascading updates.
// React to changes
const cleanup = effect(() => {
console.log("Count:", count.get(), "Doubled:", doubled.get());
});
count.set(20); // Logs: "Count: 20 Doubled: 40"
cleanup(); // Stop the effectBecause stores are just another signal, you can use computed to scope down state for fine-grained reactivity.
// Only updates when username changes
const username = computed(() => myStore.get().account.profile.username);Logging
Both store and reducer provide a withLogging function that wraps an update/reducer function with debug logging.
import { store, tx, withLogging, type Update } from "refrakt/store.js";
const update: Update<Model, Action, void> = (state, action) => {
// ...
};
const loggedUpdate = withLogging(update);
const myStore = store(loggedUpdate, initialState);import { reducer, withLogging, type Reducer } from "refrakt/reducer.js";
const step: Reducer<Model, Action> = (state, action) => {
// ...
};
const loggedStep = withLogging(step, { name: "myReducer" });
const myStore = reducer(loggedStep, initialState);Example output:
<- store { type: 'increment' }
-> store { count: 1 }You can also pass a log predicate to conditionally enable logging:
const loggedUpdate = withLogging(update, {
log: () => import.meta.env.DEV,
});Scope
scope creates a child store from a parent store. The child store's state is derived from the parent state, and all actions are tagged and routed through the parent store.
import { scope } from "refrakt/scope.js";
const childStore = scope({
store: parentStore,
// Project parent state to child state
get: (state: ParentModel) => state.child,
// Tag child actions, transforming them into parent actions
tag: (action: ChildAction): ParentAction => ({
type: "child",
action,
}),
});One way you can use scope is to create components that can be used in either an island architecture style, or in a more Elmish subcomponent style.
Components can be initialized with their own store by default. This store can be optionally overridden with a scoped store that customizes child component behavior.
// child-component.ts
import { store, tx, type StoreSignal } from "refrakt/store.js";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { watch } from "@lit-labs/signals";
// ...
@customElement("child-component")
class ChildComponent extends LitElement {
@property({ attribute: false })
store: StoreSignal<ChildModel, ChildAction> = store(update, { count: 0 });
// ...
}// parent-component.ts
import { scope } from "refrakt/scope.js";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import * as ChildComponent from "./child-component.js";
// ...
const childStore = scope({
store: parentStore,
get: (state: ParentModel) => state.child,
tag: (action: ChildAction): ParentAction => ({ type: "child", action }),
});
@customElement("parent-component")
class ParentComponent extends LitElement {
render() {
return html`
<div class="parent">
<child-component .store=${childStore}></child-component>
</div>
`;
}
}Because scoped stores are indistinguishable from parent stores, you can replace the default child store, and the child component will be none the wiser. This allows for a form of dependency injection where parent components can intercept and react to child actions, as well as customize child component behavior.
Async Iterator Utilities
The iter submodule provides utility functions for working with async generators. These can be useful for merging and mapping fx between component domains.
mergeAsync(...iterables)- Merge multiple async iterables, yielding values in interleaved order as they become availablesequenceAsync(...iterables)- Sequence async iterables, yielding all values from the first before moving to the nextmapAsync(iterable, transform)- Transform each value in an async iterable using a sync or async function
Utility Functions
forward(send, tag)- Transform a send function so that it tags actions on the way out (refrakt/send.js)tx(state, fx?)- Create a transaction with state and optional fx (refrakt/store.js)assertNever(value)- Enforces exhaustive switches vianevertype. Use in reducers to enforce exhaustive action handling (available inrefrakt/never.js)
License
MIT
