@myriadcodelabs/uiflow
v0.2.0
Published
Explicit, code-first UI flow orchestration for React.
Readme
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 definitionsstart: first step name
A step is either:
- UI step:
input,view,onOutput - Action step:
input,action,onOutput(optionalrenderpolicy)
Transition rule:
onOutputreturns next step name (string) to move forward- returning
voidkeeps the same step and re-renders
Install
pnpm add @myriadcodelabs/uiflow
# or
npm i @myriadcodelabs/uiflow
# or
yarn add @myriadcodelabs/uiflowLLM guidelines helper:
- UIFlow attempts to copy
code_generation_guidelines/uiflow_llm_guidelines.mdinto your project on install. - If install scripts are disabled in your environment, run:
npx @myriadcodelabs/uiflow install-guidelinesImports
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
startexists insteps. - Supports optional
channelTransitionsmapping (channelKey -> resolver). - A resolver receives
{ data, currentStep, events, channelKey }and returnsnextStep | void(sync/async). - Supports optional
createInitialData()for flow-local default data. - Supports optional
normalizeInitialData(data)to normalize either caller input or flow-created 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 definitioninitialData?: optional mutable per-flow data objecteventChannels?: optional channels mapeventChannelsStrategy?:"sticky"(default) or"replace"
Initialization behavior:
- If
initialDatais provided, runner uses it. - Else runner uses
flow.createInitialData()(if provided). - If neither exists, runner throws.
- If
flow.normalizeInitialDataexists, it is applied to whichever data source is used.
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
- Keep views dumb: render from
input, emit intent viaoutput.emit. - Keep transition logic in
onOutputonly. - Use discriminated unions for UI output types.
- Co-locate domain state (example: card + flipped + rating in one structure).
- Use helper functions for repeated state ops.
- Split long flows into focused steps (
fetch,decide,view,commit).
Important runtime behavior
- A step is treated as action step when it has
actionand does not haveview. - Action step runs automatically when it becomes current.
FlowRunnernormalizes channels before subscribing:"sticky"(default): keeps first-seen channel instance per key."replace": uses the latest incoming channel instances.
- Channel emissions trigger re-render for subscribed runners.
- If
channelTransitions[channelKey]exists, channelemitruns that resolver and transitions when a valid step is returned. - Action steps render
nullby default while running. render.mode = "preserve-previous"keeps previous UI step rendered while the action is running.render.mode = "fallback"renders the action step fallback view while the action is running.- Errors in
onOutput, action steps, or channel transition resolvers are logged (console.error) and not rethrown. - Returning unknown step or
voiddoes not change current step. initialDatais shallow-copied at runner initialization.
Pitfalls to avoid
- Creating channel instances directly in render can reset channel value if keys change or if using
"replace"strategy. - Rebuilding
eventChannelsobject each render is safe;FlowRunnerdeduplicates equivalent maps internally. - Using
output.done(...)instead ofoutput.emit(...). - Mixing
viewandactionin the same step. - Returning transition targets that do not exist.
- Using static values in
channelTransitions; each channel entry must be a resolver function. - Assuming action steps auto-render a loading placeholder without configuring
render.
Next.js notes
FlowRunnerand 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
startexists and all transitions target valid step keys.- UI outputs are typed unions.
- Views only emit intent.
- Async work is in action steps.
- Channels are stable and reused.
- No internal-path imports.
License
MIT
