LangGraph in Practice: State, Nodes, Edges, and Five Agent Patterns
Learn how to define LangGraph state with Annotation and reducers, wire nodes with edges, and implement all five core agentic workflow patterns in JavaScript.
This article is published in English.
Sources: Agentic AI and LangGraph — Part 2: Building Nodes, Edges, and All 5 Workflow Patterns
Recap: Where We Left Off
In the previous installment, we laid out the conceptual foundation: an AI agent pairs a brain (the LLM) with an orchestrator that coordinates tools and memory, all wrapped in some form of oversight. We also saw that LangGraph turns this into a graph — nodes hold units of work, edges control the flow between them, and a shared state object travels through the whole system, getting merged at each step by reducers.
This time, we move from theory to implementation. By the time you finish this post, you'll be able to:
- Declare state using
Annotation, including custom reducers - Wire nodes together using both plain and conditional edges
- Implement all five workflow patterns from the previous post — prompt chaining, routing, parallelization, orchestrator-worker, and evaluator-optimizer — in working JavaScript
- Reference a diagram for each pattern that shows exactly how the pieces connect
All the code below relies on @langchain/langgraph and plain JavaScript (the same patterns apply in TypeScript, just add type parameters to Annotation<T>).
npm install @langchain/langgraph @langchain/core @langchain/openai
1. State and Annotations — The Shared Notebook
Before a single node can run, LangGraph needs to understand the structure of the data passing through your graph. Annotation is how you describe that structure.
import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
const WorkflowState = Annotation.Root({
query: Annotation(),
searchResults: Annotation(),
draft: Annotation(),
finalAnswer: Annotation(),
});
Every key you define inside Annotation.Root({...}) becomes a field on your shared state object. Any node can read from these fields, and when it returns, it only needs to include the fields it's actually updating — LangGraph takes care of merging that partial result back into the full state.
Reducers: Deciding How Updates Merge
Unless you specify otherwise, a node's return value replaces the corresponding field entirely. That works fine for something like draft, where you only ever care about the latest version. But it breaks down for things like an accumulating list of messages, where overwriting would erase everything that came before. This is where a reducer helps — it's a function that defines how to merge the existing value with an incoming update.
const AgentState = Annotation.Root({
// Default behavior: last write wins
draft: Annotation(),
// Custom reducer: append instead of overwrite
messages: Annotation({
reducer: (current, update) => [...current, ...update],
default: () => [],
}),
});
With this in place, if nodeA returns { messages: ["hi"] } and nodeB returns { messages: ["hello"] }, the reducer merges them into ["hi", "hello"] rather than letting one overwrite the other. This mechanism is essential for running parallel branches safely (see pattern 3 below) — two nodes can write to the same state key simultaneously without conflicting.
2. Nodes — Units of Work
A node is nothing more than a function — synchronous or async — that receives the current state and returns a partial update to it.
async function draftNode(state) {
const response = await llm.invoke(`Write a short draft about: ${state.query}`);
return { draft: response.content };
}
You register it on the graph using addNode:
const graph = new StateGraph(WorkflowState)
.addNode("draft", draftNode);
Notice that the node itself has no awareness of the surrounding graph structure. It simply consumes state and produces an update. Connecting nodes together is entirely the job of edges.
3. Edges — Wiring the Flow
Edges determine execution order. LangGraph supports three kinds:
Normal edges — a fixed, unconditional path from one node to another.
graph.addEdge(START, "draft"); // entry point: where the graph starts
graph.addEdge("draft", "polish");
graph.addEdge("polish", END); // exit point: where the graph stops
Conditional edges — a routing function examines the current state and decides which node runs next.
graph.addConditionalEdges("classify", (state) => {
if (state.category === "billing") return "handleBilling";
if (state.category === "technical") return "handleTechnical";
return "handleGeneral";
});
Parallel (fan-out) edges — when a single node has multiple outgoing normal edges, all of their targets execute concurrently.
graph.addEdge("orchestrator", "workerA");
graph.addEdge("orchestrator", "workerB");
graph.addEdge("orchestrator", "workerC");
Every graph must include at least one edge starting from START and a route that eventually reaches END, otherwise LangGraph won't know where execution should begin or terminate.
With state, nodes, and edges now covered, you have all the building blocks required to construct the five workflow patterns introduced earlier.
4. Pattern 1 — Prompt Chaining
Concept: split a large task into a linear chain of LLM calls, where each step builds on what came before.
START → outline → draft → polish → END
const ChainState = Annotation.Root({
topic: Annotation(),
outline: Annotation(),
draft: Annotation(),
final: Annotation(),
});
async function outlineNode(state) {
const res = await llm.invoke(`Create a short outline for: ${state.topic}`);
return { outline: res.content };
}async function draftNode(state) {
const res = await llm.invoke(`Write a draft based on this outline:\n${state.outline}`);
return { draft: res.content };
}async function polishNode(state) {
const res = await llm.invoke(`Polish and tighten this draft:\n${state.draft}`);
return { final: res.content };
}const chainGraph = new StateGraph(ChainState)
.addNode("outline", outlineNode)
.addNode("draft", draftNode)
.addNode("polish", polishNode)
.addEdge(START, "outline")
.addEdge("outline", "draft")
.addEdge("draft", "polish")
.addEdge("polish", END)
.compile();const result = await chainGraph.invoke({ topic: "the history of tea" });
console.log(result.final);
Every node here reads only the field written by the node before it — there's no branching or looping, just a straight pipeline from start to finish.
5. Pattern 2 — Routing
Concept: first classify the incoming input, then forward it to one of several dedicated handling paths.
┌──> handleBilling ──┐
START → classify ───┼──> handleTechnical ─┼──> END
└──> handleGeneral ──┘
const RouteState = Annotation.Root({
message: Annotation(),
category: Annotation(),
response: Annotation(),
});
async function classifyNode(state) {
const res = await llm.invoke(
`Classify this message as "billing", "technical", or "general". Only reply with one word.\n${state.message}`
);
return { category: res.content.trim().toLowerCase() };
}async function handleBilling(state) {
return { response: `[Billing team] ${await llm.invoke(state.message).then(r => r.content)}` };
}
async function handleTechnical(state) {
return { response: `[Tech support] ${await llm.invoke(state.message).then(r => r.content)}` };
}
async function handleGeneral(state) {
return { response: `[General desk] ${await llm.invoke(state.message).then(r => r.content)}` };
}const routeGraph = new StateGraph(RouteState)
.addNode("classify", classifyNode)
.addNode("handleBilling", handleBilling)
.addNode("handleTechnical", handleTechnical)
.addNode("handleGeneral", handleGeneral)
.addEdge(START, "classify")
.addConditionalEdges("classify", (state) => {
if (state.category === "billing") return "handleBilling";
if (state.category === "technical") return "handleTechnical";
return "handleGeneral";
})
.addEdge("handleBilling", END)
.addEdge("handleTechnical", END)
.addEdge("handleGeneral", END)
.compile();
The function you pass to addConditionalEdges needs to return the name of the next node to visit — that's the mechanism that lets the graph choose a path dynamically instead of always following the same route.
6. Pattern 3 — Parallelization
Concept: fire off multiple LLM calls simultaneously against different chunks of a task, then combine what comes back.
┌──> summarizeDocA ──┐
START → split ──┼──> summarizeDocB ──┼──> merge → END
└──> summarizeDocC ──┘
const ParallelState = Annotation.Root({
documents: Annotation(),
summaries: Annotation({
reducer: (current, update) => [...current, ...update],
default: () => [],
}),
finalSummary: Annotation(),
});
function splitNode(state) {
return {}; // no-op entry point, just fans out to the summarizers below
}async function summarizeDocA(state) {
const res = await llm.invoke(`Summarize:\n${state.documents[0]}`);
return { summaries: [res.content] };
}
async function summarizeDocB(state) {
const res = await llm.invoke(`Summarize:\n${state.documents[1]}`);
return { summaries: [res.content] };
}
async function summarizeDocC(state) {
const res = await llm.invoke(`Summarize:\n${state.documents[2]}`);
return { summaries: [res.content] };
}async function mergeNode(state) {
const res = await llm.invoke(`Combine these summaries into one:\n${state.summaries.join("\n---\n")}`);
return { finalSummary: res.content };
}const parallelGraph = new StateGraph(ParallelState)
.addNode("split", splitNode)
.addNode("summarizeDocA", summarizeDocA)
.addNode("summarizeDocB", summarizeDocB)
.addNode("summarizeDocC", summarizeDocC)
.addNode("merge", mergeNode)
.addEdge(START, "split")
.addEdge("split", "summarizeDocA")
.addEdge("split", "summarizeDocB")
.addEdge("split", "summarizeDocC")
.addEdge("summarizeDocA", "merge")
.addEdge("summarizeDocB", "merge")
.addEdge("summarizeDocC", "merge")
.addEdge("merge", END)
.compile();
Pay attention to the reducer attached to summaries: without it, the last parallel branch to complete would simply overwrite the previous results instead of letting all three outputs accumulate safely into the array.
7. Pattern 4 — Orchestrator-Worker
Concept: a single LLM call splits the overall job into smaller subtasks and hands each one to a worker node, then a final step assembles everything into one result. This looks a lot like parallelization, but here the orchestrator decides on the fly how many workers to spin up and what each one should do, rather than that being fixed ahead of time.
START → orchestrator ──> worker(s), one per subtask ──> compile → END
const OrchestratorState = Annotation.Root({
task: Annotation(),
subtasks: Annotation(),
sections: Annotation({
reducer: (current, update) => [...current, ...update],
default: () => [],
}),
report: Annotation(),
});
async function orchestratorNode(state) {
const res = await llm.invoke(
`Break this task into 3-5 short subtasks, one per line:\n${state.task}`
);
const subtasks = res.content.split("\n").filter(Boolean);
return { subtasks };
}async function workerNode(state) {
// In a real app you'd invoke one worker per subtask (e.g. via Send / map-reduce);
// here's the simple per-subtask logic each worker runs:
const results = await Promise.all(
state.subtasks.map((subtask) => llm.invoke(`Write a section about: ${subtask}`))
);
return { sections: results.map((r) => r.content) };
}async function compileNode(state) {
const res = await llm.invoke(`Compile these sections into one report:\n${state.sections.join("\n\n")}`);
return { report: res.content };
}const orchestratorGraph = new StateGraph(OrchestratorState)
.addNode("orchestrator", orchestratorNode)
.addNode("worker", workerNode)
.addNode("compile", compileNode)
.addEdge(START, "orchestrator")
.addEdge("orchestrator", "worker")
.addEdge("worker", "compile")
.addEdge("compile", END)
.compile();
If you need real dynamic fan-out — meaning the number of worker branches actually changes from run to run — LangGraph.js offers the Send API, which lets you spawn one worker invocation per subtask at execution time. That mechanism deserves its own detailed writeup, but the setup shown here already covers the large majority of everyday needs, such as drafting reports or building research agents.
8. Pattern 5 — Evaluator-Optimizer
Concept: one node generates an output, a second node checks it, and a conditional edge routes execution back to the generator until the output is accepted. This is the pattern where LangGraph's support for cycles actually gets exercised.
START → generate → evaluate ──(fail)──> generate (loop)
│
(pass)
↓
END
const LoopState = Annotation.Root({
prompt: Annotation(),
code: Annotation(),
feedback: Annotation(),
passed: Annotation(),
attempts: Annotation({
reducer: (current, update) => current + update,
default: () => 0,
}),
});
async function generateNode(state) {
const context = state.feedback ? `\nPrevious feedback: ${state.feedback}` : "";
const res = await llm.invoke(`Write code for: ${state.prompt}${context}`);
return { code: res.content, attempts: 1 };
}async function evaluateNode(state) {
const res = await llm.invoke(
`Review this code for bugs. Reply "PASS" if it's correct, otherwise explain what's wrong:\n${state.code}`
);
const passed = res.content.trim().startsWith("PASS");
return { passed, feedback: passed ? null : res.content };
}const loopGraph = new StateGraph(LoopState)
.addNode("generate", generateNode)
.addNode("evaluate", evaluateNode)
.addEdge(START, "generate")
.addEdge("generate", "evaluate")
.addConditionalEdges("evaluate", (state) => {
if (state.passed || state.attempts >= 4) return END; // safety cap on retries
return "generate";
})
.compile();
Notice that the attempts field uses a reducer that adds to the running total instead of replacing it — this also acts as a guardrail. Without some kind of cap on retries, an evaluator that never approves the output could keep the loop running indefinitely.
9. Compiling and Running Any of These
No matter which pattern you build, you finish the same way:
const app = graph.compile();
const finalState = await app.invoke({ /* initial state fields */ });
Calling .compile() checks that every node can actually be reached, confirms START and END are properly connected, and converts your graph description into an executable object. .invoke() then runs that graph from beginning to end and returns the resulting state — this is the same execution cycle described conceptually earlier in this series.
Wrapping Up
Over the course of this walkthrough, the ideas from theory became runnable code: Annotation.Root to define state, ordinary functions as nodes, addEdge and addConditionalEdges to control the flow, and reducers to keep concurrent or repeated updates from clobbering each other. With just those four pieces, every one of the five workflow patterns covered — chaining, routing, parallelization, orchestrator-worker, and evaluator-optimizer — turns out to be a different combination of the same underlying tools.
The next part in this series will bring in external tools, add memory so state can persist between sessions, and introduce a step where a person reviews and approves actions before they proceed — the pieces needed to turn these graphs into a genuine agent.