Hello @lofti198, welcome to langchain community.
LangGraph is the right primitive for your wizard flows
1. Deterministic branching (not LLM-driven)
LangGraph does not require LLM-driven transitions. Any node , even a pure utility function , can return a Command that specifies the next node deterministically:
const analyzePageType = async (state) => {
const type = detectPageType(state.url); // zero LLM, pure logic
return new Command({
update: { pageType: type },
goto: type === "list" ? "configureListStep" : "configureDetailStep",
});
};
goto accepts a single node name, an array of names, or Send objects for parallel fan-out. The branching is yours to control, not the model’s. Docs: Command API Reference
2. Serializable pause/resume across sessions
This is the exact problem interrupt() + the checkpointer was built for. The mechanics:
interrupt() inside a node suspends execution and serializes full graph state to your checkpointer backend (Postgres in production).
- The
thread_id in your config is the persistent key — store it in your DB against the user record.
- When the user returns, invoke the same graph with the same
thread_id and a Command({ resume: userInput }). Execution picks up from the exact interrupted line, with all prior task() results cached and not re-run.
Docs: Persistence & Checkpointing
3. Multiple wizard entry points
Use the Functional API (entrypoint + task), not StateGraph. Each wizard becomes its own entrypoint , a regular async function with no graph topology to declare. Branching is plain if/else, loops are plain for loops, and each interrupt() is just a await-able pause:
import { entrypoint, task } from "@langchain/langgraph/func";
const scraperWizard = entrypoint(
{ checkpointer, name: "scraperWizard" },
async (input: { url: string }) => {
const rules = await analyzeUrl(input.url).then(r => r.result); // task() — cached on resume
const approval = interrupt({ rules, prompt: "Approve or give feedback" });
if (approval.type === "feedback") {
const revised = await adjustRules({ rules, feedback: approval.text }).then(r => r.result);
interrupt({ rules: revised, prompt: "Confirm revised rules" });
}
const linkConfig = interrupt({ prompt: "Configure link collection" });
return { rules, linkConfig };
}
);
const linkSetupWizard = entrypoint( // separate entry point
{ checkpointer, name: "linkSetupWizard" },
async (input) => { /* ... */ }
);
Both wizards share one Postgres checkpointer instance , no cross-contamination of state, unified persistence. Docs: Functional API Concepts
4. Clean mid-flow exit when user abandons or switches intent
An abandoned thread is just a frozen checkpoint, no process is running, no cost incurred. The pattern is simple:
| User action |
What you do |
| Abandons wizard |
Stop invoking. Thread stays frozen in Postgres. |
| Comes back later |
Load saved thread_id, call graph.invoke(new Command({ resume: ... })) |
| Switches to different intent entirely |
Start a new thread_id for the new flow. Old thread stays untouched. |
| Permanently discards a wizard |
Delete the checkpoint row from Postgres. |
Docs: Threads & State Management
Recommended architecture for your app
Next.js frontend
│
│ thread_id per wizard session (stored in your DB per user)
↓
LangGraph Functional API (JS)
├── scraperWizard entrypoint ─┐
├── linkSetupWizard entrypoint ├── shared Postgres checkpointer
└── researchGraph StateGraph ─┘ (your existing ReAct flow)
Your ReAct decompose→parallelize→synthesize flow stays as a StateGraph (topology matters there). The wizard flows use entrypoint (control flow is sequential and conditional). Both run on the same LangGraph runtime and the same Postgres checkpointer. No XState, no custom FSM layer needed.
The one case to genuinely reach for XState
If a wizard has zero AI steps, purely routing user input through deterministic transitions with no task() calls; then LangGraph’s serialization and checkpoint overhead adds complexity with no payoff. XState’s visual state chart tooling is also better for documenting complex pure-UX flows to non-engineers. But if there’s a single LLM call in the loop, LangGraph’s task() result caching on resume alone makes it worth it.