Skip to content

A react library for defining UI flows explicitly using input → view → output invariants.

License

Notifications You must be signed in to change notification settings

myriadcodelabs/uiflow

Repository files navigation

UIFlow

Code-first flow orchestration for React.

UIFlow helps you build multi-step UI without scattering state and transition logic across many components. You define steps in one place, and each step decides what comes next.

Why UIFlow

  • Keep flow logic explicit: step names + transitions are centralized.
  • Mix UI and async logic naturally: both are first-class steps.
  • Share state across independent flows with channels.
  • Stay in plain TypeScript objects, not custom DSLs.

Mental model (60 seconds)

A flow is:

  • steps: a map of step names to step definitions
  • start: first step name

A step is either:

  • UI step: input, view, onOutput
  • Action step: input, action, onOutput (optional render policy)

Transition rule:

  • onOutput returns next step name (string) to move forward
  • returning void keeps the same step and re-renders

Install

pnpm add @myriadcodelabs/uiflow
# or
npm i @myriadcodelabs/uiflow
# or
yarn add @myriadcodelabs/uiflow

LLM guidelines helper:

  • UIFlow attempts to copy code_generation_guidelines/uiflow_llm_guidelines.md into your project on install.
  • If install scripts are disabled in your environment, run:
npx @myriadcodelabs/uiflow install-guidelines

Imports

import { FlowRunner, defineFlow, createFlowChannel, type OutputHandle } from "@myriadcodelabs/uiflow";

Use package-root imports only.

Quick start (minimal runnable example)

"use client";

import { FlowRunner, defineFlow, type OutputHandle } from "@myriadcodelabs/uiflow";

type Data = { name: string };
type AskNameOutput = { action: "setName"; value: string } | { action: "submit" };

function AskNameView(props: {
  input: { name: string };
  output: OutputHandle<AskNameOutput>;
}) {
  return (
    <div>
      <input
        value={props.input.name}
        onChange={(e) => props.output.emit({ action: "setName", value: e.target.value })}
        placeholder="Your name"
      />
      <button onClick={() => props.output.emit({ action: "submit" })}>Continue</button>
    </div>
  );
}

function DoneView(props: { input: { message: string }; output: OutputHandle<never> }) {
  return <h2>{props.input.message}</h2>;
}

const onboardingFlow = defineFlow<Data>(
  {
    askName: {
      input: (data) => ({ name: data.name }),
      view: AskNameView,
      onOutput: (data, output) => {
        if (output.action === "setName") {
          data.name = output.value;
          return;
        }
        if (output.action === "submit") {
          return "done";
        }
      },
    },

    done: {
      input: (data) => ({ message: `Welcome, ${data.name || "friend"}!` }),
      view: DoneView,
      onOutput: () => {},
    },
  },
  { start: "askName" }
);

export function App() {
  return <FlowRunner flow={onboardingFlow} initialData={{ name: "" }} />;
}

Practical pattern: study/review flow (real-world)

This pattern is taken from practical flashcards usage.

import { defineFlow } from "@myriadcodelabs/uiflow";

type Data = {
  deckId: string;
  flowData: {
    cards: Array<{ id: string; flipped: boolean; rating: "easy" | "good" | "hard" | "again" | null }>;
    activeCardId: string | null;
  };
};

type StudyOutput =
  | { action: "flip"; cardId: string }
  | { action: "rate"; cardId: string; rating: "easy" | "good" | "hard" | "again" }
  | { action: "next"; cardId: string };

export const studyFlow = defineFlow<Data>(
  {
    fetchCards: {
      input: (data) => ({ deckId: data.deckId }),
      action: async ({ deckId }, data) => {
        const cards = await fetchCardsListAction(deckId);
        data.flowData.cards = (cards ?? []).map((c) => ({ id: c.id, flipped: false, rating: null }));
        data.flowData.activeCardId = null;
        return { ok: true };
      },
      onOutput: () => "decide",
    },

    decide: {
      input: (data) => ({ hasCards: data.flowData.cards.length > 0 }),
      action: ({ hasCards }) => hasCards,
      onOutput: (_, hasCards) => (hasCards ? "study" : "empty"),
    },

    study: {
      input: (data) => ({ cards: data.flowData.cards, activeCardId: data.flowData.activeCardId }),
      view: StudyCardsView,
      onOutput: (data, output: StudyOutput, events) => {
        if (output.action === "flip") {
          data.flowData.activeCardId = output.cardId;
          const card = data.flowData.cards.find((c) => c.id === output.cardId);
          if (card) card.flipped = true;
          return "study";
        }

        if (output.action === "rate") {
          data.flowData.activeCardId = output.cardId;
          const card = data.flowData.cards.find((c) => c.id === output.cardId);
          if (card) card.rating = output.rating;
          return "review";
        }

        if (output.action === "next") {
          events?.studiedCounter.emit((n: number) => n + 1);
          data.flowData.activeCardId = null;
          return "fetchCards";
        }
      },
    },

    review: {
      input: (data) => ({
        deckId: data.deckId,
        cardId: data.flowData.activeCardId,
        rating: data.flowData.cards.find((c) => c.id === data.flowData.activeCardId)?.rating,
      }),
      action: async ({ deckId, cardId, rating }) => {
        await reviewCard(deckId, cardId, rating);
        return { ok: true };
      },
      onOutput: (data, _, events) => {
        events?.studiedCounter.emit((n: number) => n + 1);
        data.flowData.activeCardId = null;
        return "fetchCards";
      },
    },

    empty: {
      input: () => ({}),
      view: EmptyView,
      onOutput: () => {},
    },
  },
  { start: "fetchCards" }
);

Cross-flow communication with channels

Use channels when two independent flows need shared reactive state.

"use client";

import { useMemo } from "react";
import { createFlowChannel, FlowRunner } from "@myriadcodelabs/uiflow";

export function FlashcardsScreen({ deckId }: { deckId: string }) {
  const studiedCounter = useMemo(() => createFlowChannel<number>(0), []);
  const channels = useMemo(() => ({ studiedCounter }), [studiedCounter]);

  return (
    <>
      <FlowRunner flow={counterFlow} initialData={{}} eventChannels={channels} />
      <FlowRunner
        flow={studyFlow}
        initialData={{ deckId, flowData: { cards: [], activeCardId: null } }}
        eventChannels={channels}
      />
    </>
  );
}

API reference

defineFlow(steps, { start })

  • Validates start exists in steps.
  • Supports optional channelTransitions mapping (channelKey -> resolver).
  • A resolver receives { domain, internal, currentStep, events, channelKey } and returns nextStep | void (sync/async).
  • Supports optional createInternalData() for flow-owned internal state defaults.
  • Returns flow definition consumed by FlowRunner.

Example:

const flow = defineFlow(
  {
    fetchList: { /* ... */ },
    showList: { /* ... */ },
  },
  {
    start: "fetchList",
    channelTransitions: {
      refresh: ({ events, currentStep }) => {
        const refreshCount = events?.refresh.get() ?? 0;
        if (refreshCount > 0 && currentStep !== "fetchList") return "fetchList";
        return;
      },
    },
  }
);

FlowRunner

<FlowRunner flow={flow} initialData={initialData} eventChannels={channels} />

Props:

  • flow: flow definition
  • initialData: required domain data object (caller-owned input)
  • eventChannels?: optional channels map
  • eventChannelsStrategy?: "sticky" (default) or "replace"

Initialization behavior:

  • Runner always uses initialData as domain data.
  • Runner initializes internal state from flow.createInternalData() when provided, otherwise {}.

Action-step render policy:

  • Default: action step renders nothing while running.
  • Per action step, you can override with:
    • render: { mode: "preserve-previous" }
    • render: { mode: "fallback", view: SavingView }

createFlowChannel(initial)

Creates channel with:

  • get()
  • emit(update)
  • subscribe(listener)

OutputHandle<O>

UI steps emit events with:

  • output.emit(payload)

How to keep flows manageable

  1. Keep views dumb: render from input, emit intent via output.emit.
  2. Keep transition logic in onOutput only.
  3. Use discriminated unions for UI output types.
  4. Co-locate domain state (example: card + flipped + rating in one structure).
  5. Use helper functions for repeated state ops.
  6. Split long flows into focused steps (fetch, decide, view, commit).

Important runtime behavior

  1. A step is treated as action step when it has action and does not have view.
  2. Action step runs automatically when it becomes current.
  3. FlowRunner normalizes channels before subscribing:
    • "sticky" (default): keeps first-seen channel instance per key.
    • "replace": uses the latest incoming channel instances.
  4. Channel emissions trigger re-render for subscribed runners.
  5. If channelTransitions[channelKey] exists, channel emit runs that resolver and transitions when a valid step is returned.
  6. Action steps render null by default while running.
  7. render.mode = "preserve-previous" keeps previous UI step rendered while the action is running.
  8. render.mode = "fallback" renders the action step fallback view while the action is running.
  9. Errors in onOutput, action steps, or channel transition resolvers are logged (console.error) and not rethrown.
  10. Returning unknown step or void does not change current step.
  11. initialData is shallow-copied at runner initialization.

Pitfalls to avoid

  1. Creating channel instances directly in render can reset channel value if keys change or if using "replace" strategy.
  2. Rebuilding eventChannels object each render is safe; FlowRunner deduplicates equivalent maps internally.
  3. Using output.done(...) instead of output.emit(...).
  4. Mixing view and action in the same step.
  5. Returning transition targets that do not exist.
  6. Using static values in channelTransitions; each channel entry must be a resolver function.
  7. Assuming action steps auto-render a loading placeholder without configuring render.

Next.js notes

  • FlowRunner and UI step views should be in client components.
  • Add "use client" at the top where needed.
  • Server actions can be called inside action steps.

FAQ

Why not just useState + useEffect?

You can for simple screens. UIFlow is useful when screens become multi-step and transitions/side-effects spread across components.

Is flow data immutable?

No. Flow data is mutable by design inside step handlers.

Can I have multiple flows on one page?

Yes. Use channels when they need to communicate.

Complete checklist before shipping

  1. start exists and all transitions target valid step keys.
  2. UI outputs are typed unions.
  3. Views only emit intent.
  4. Async work is in action steps.
  5. Channels are stable and reused.
  6. No internal-path imports.

License

MIT

About

A react library for defining UI flows explicitly using input → view → output invariants.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published