Skip to content

robCrawford/pure-ui-actions

Repository files navigation

pure-ui-actions

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.

Examples:


Actions and tasks

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)

Props and state

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

Hello World!

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;

DOM Events

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") }
      })

Redux DevTools Integration

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:

  1. Install the Redux DevTools Extension for your browser
  2. Open your app
  3. Open browser DevTools → Redux tab
  4. Watch actions and state updates in real-time

Logging controls:

  • Redux DevTools logging is automatic when the extension is installed
  • Add ?debug=console to the URL for comprehensive logging including renders. Errors and your own logs are accurately located within full lifecycle logs

Unit tests

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 });
    });
  });

});

Testing Actions with Custom Context

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
});

VDOM Optimizations

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.


Additional APIs

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 events
  • setHook(vnode, hookName, callback) - Access VDOM lifecycle hooks

See AGENTS.md for complete documentation on these APIs and when to use them.


Redux Comparison

Both Redux and pure-ui-actions emphasize pure functions for state updates, but with different patterns:

Redux: Actions as Data

// 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;
  }
}

pure-ui-actions: Actions as Functions

// 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 }
  });
}

Key Insight

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

About

Pure type-safe actions that humans and AI can follow

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •