Minimal wiring for declarative actions with a fast vdom library. Co-authored with an AI agent to produce clear state flows that humans and AI can follow.
- Emphasis on pure functions
- Named actions with deferred effects, allow testing without mocks and works with redux dev tools
- Data flow inspired by The Elm Architecture, see also Redux comparison below
- Uses Snabbdom VDOM and is optimized for minimal renders
- AGENTS.md (written by AI)
- Single page app demo [source]
- Hello World [source]
The component callback receives action, task, rootAction and rootTask functions (which output thunks)
export default component(
({ action, task, rootAction, rootTask }) => ({
// Initial action
init: action( "ShowMessage", { text: "Hello World!" } ),
})
);When an action thunk runs, its handler returns new state and any next actions/tasks (see Hello World below)
Task thunks provide effect handlers that the framework executes (see Unit tests below for testing without mocks)
The view function receives props, state and rootState for rendering
view(id, { props, state, rootState }) {
return div(`#${id}-message`, [
// Render from props and state
h1(props.title),
div(state.text)
]);
}All action handlers and task callbacks also receive these inputs
import { component, html, mount } from "pure-ui-actions";
import { setDocTitle } from "./services/browser";
const { h3, div } = html;
export type Props = Readonly<{
date: string;
}>;
export type State = Readonly<{
title: string;
text: string;
done: boolean;
}>;
export type ActionPayloads = Readonly<{
ShowMessage: { text: string };
PageReady: { done: boolean };
}>;
export type TaskPayloads = Readonly<{
SetDocTitle: { title: string };
}>;
export type Component = {
Props: Props;
State: State;
ActionPayloads: ActionPayloads;
TaskPayloads: TaskPayloads;
};
const app = component<Component>(({ action, task }) => ({
// Initial state
state: (props) => ({
title: `Welcome! ${props.date}`,
text: "",
done: false
}),
// Initial action
init: action("ShowMessage", { text: "Hello World!" }),
// Action handlers return new state, and any next actions/tasks
actions: {
ShowMessage: (data, context) => {
return {
state: {
...context.state,
text: data.text
},
next: task("SetDocTitle", { title: data.text })
};
},
PageReady: (data, context) => {
return {
state: {
...context.state,
done: data.done
}
};
}
},
// Task handlers provide callbacks for effects and async operations that may fail
tasks: {
SetDocTitle: (data) => ({
perform: () => setDocTitle(data.title),
success: () => action("PageReady", { done: true }),
failure: () => action("PageReady", { done: false })
})
},
// View renders from props & state
view(id, context) {
return div(`#${id}-message`, [
h3(context.state.title),
div(context.state.text),
div(context.state.done ? "✅" : "❎")
]);
}
}));
document.addEventListener("DOMContentLoaded", () =>
mount({ app, props: { date: new Date().toDateString() } })
);
export default app;An event prop is also passed to action handlers when run from the DOM
actions: {
Input: (_, { props, state, event }) => ({
state: {
...state,
text: event?.target?.value ?? ""
}
})
},
view: (id, { state }) =>
html.input(`#${id}-input`, {
props: { value: state.text },
on: { input: action("Input") }
})pure-ui-actions automatically integrates with Redux DevTools browser extension for enhanced debugging:
- Action History - See all actions fired with their payloads
- State Inspector - View component states in a tree structure
- State Diff - Automatically see what changed with each action
- Task Tracking - Monitor async operations (success/failure)
Setup:
- Install the Redux DevTools Extension for your browser
- Open your app
- Open browser DevTools → Redux tab
- Watch actions and state updates in real-time
Logging controls:
- Redux DevTools logging is automatic when the extension is installed
- Add
?debug=consoleto the URL for comprehensive logging including renders. Errors and your own logs are accurately located within full lifecycle logs
In tests, actionTest and taskTest functions return plain data, so component logic can be tested without mocks or executing actual effects
import { componentTest, NextData } from "pure-ui-actions";
import app, { State } from "./app";
describe("App", () => {
const { actionTest, taskTest, config, initialState } = componentTest(app, { placeholder: "placeholder" });
it("should set initial state", () => {
expect(initialState).toEqual({ text: "placeholder", done: false });
});
it("should run initial action", () => {
expect(config.init).toEqual({
name: "ShowMessage",
data: { text: "Hello World!" }
});
});
describe("'ShowMessage' action", () => {
const { state, next } = actionTest<State>("ShowMessage", { text: "Hello World!"});
it("should update state", () => {
expect(state).toEqual({
...initialState,
text: "Hello World!"
});
});
it("should return next", () => {
const { name, data } = next as NextData;
expect(name).toBe("SetDocTitle");
expect(data).toEqual({ title: "Hello World!" });
});
});
describe("'SetDocTitle' task", () => {
const { perform, success, failure } = taskTest("SetDocTitle", { title: "test" });
it("should provide perform", () => {
expect(perform).toBeDefined();
});
it("should handle success", () => {
const { name, data } = success() as NextData;
expect(name).toBe("PageReady");
expect(data).toEqual({ done: true });
});
it("should handle failure", () => {
const { name, data } = failure() as NextData;
expect(name).toBe("PageReady");
expect(data).toEqual({ done: false });
});
});
});Pass an optional third parameter to test actions with specific state, rootState, or events:
// Test with custom state
const { state } = actionTest("ProcessData", { value: 10 }, {
state: { count: 5, data: [] }
});
// Test action that accesses rootState
const { state } = actionTest("ApplyTheme", {}, {
state: initialState,
rootState: { theme: "dark" }
});
// Test action that accesses DOM event
const mockEvent = { target: { value: "test input" } };
const { state } = actionTest("HandleInput", {}, {
state: initialState,
event: mockEvent
});Snabbdom's key for list diffing and memo (thunk) for memoization are available. See AGENTS.md for usage patterns and examples/spa/src/components/datesList.ts for a working example.
pure-ui-actions provides additional utilities for advanced use cases:
subscribe(event, handler)/unsubscribe(event, handler)- Subscribe to framework lifecycle events (like"patch")publish(event, detail?)- Emit custom application eventssetHook(vnode, hookName, callback)- Access VDOM lifecycle hooks
See AGENTS.md for complete documentation on these APIs and when to use them.
Both Redux and pure-ui-actions emphasize pure functions for state updates, but with different patterns:
// 1. Action creator returns plain object
const increment = (step) => ({
type: "INCREMENT",
payload: { step }
});
// 2. Dispatch the action
dispatch(increment(5));
// 3. Reducer handles the action (pure function)
function counterReducer(state, action) {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + action.payload.step };
default:
return state;
}
}// 1. action() creates a thunk
const incrementThunk = action("Increment", { step: 5 });
// 2. Framework invokes handler (pure function)
actions: {
Increment: ({ step }, { state }) => ({
state: { ...state, count: state.count + step }
});
}In pure-ui-actions, action() combines both action creator and dispatch into a single deferred function. The action handler (equivalent to a Redux reducer) is still a pure function called by the framework.
Differences:
- Redux actions are plain data; pure-ui-actions actions are functions
- pure-ui-actions has built-in async handling (Tasks)
- Automatic action thunk memoization vs manual selector memoization