How state works in LangGraph.js
When you create a StateGraph, you pass it a state schema defined with Annotation.Root(). This schema defines what data the graph operates on. Every node receives the full state as input and returns a partial update object.
By default, updates overwrite existing values. If a node returns { currentStep: "classify" }, the currentStep field gets set to "classify". But for list fields like message histories, you usually want to append rather than replace. That is what reducers are for.
Reducers are functions that define how updates to a field are combined. The built-in messagesStateReducer appends new messages and deduplicates by message ID. You can write custom reducers for any merging logic you need.
Building blocks
The five components that make up every LangGraph.js workflow.
StateGraph
The main class. Pass it an Annotation schema, add nodes and edges, then call compile() to get a runnable graph.
Annotation.Root()
Defines the state schema. Each field can have a reducer function and a default value. This replaces TypedDict from the Python version.
Nodes
Async functions that do the work. Each node takes the full state as input and returns a partial update object. Only the keys you return get updated.
Edges
Connections between nodes. Normal edges always go to the same target. Conditional edges call a routing function to decide where to go based on state.
Reducers
Functions that control how state updates merge. Without a reducer, new values replace old ones. The messagesStateReducer appends messages and deduplicates by ID.
Defining state with Annotation
State is defined with Annotation.Root(). Fields without a reducer use last-write-wins semantics. Fields with a reducer function control how updates are merged.
import { Annotation } from "@langchain/langgraph"
const StateAnnotation = Annotation.Root({
// With reducer: new messages are appended
messages: Annotation<BaseMessage[]>({
reducer: (prev, next) => [...prev, ...next],
default: () => [],
}),
// Without reducer: last write wins
currentStep: Annotation<string>,
// Custom reducer: values are added together
totalCost: Annotation<number>({
reducer: (prev, next) => prev + next,
default: () => 0,
}),
})Using MessagesAnnotation
For agent-style graphs that primarily work with message lists, LangGraph.js provides MessagesAnnotation as a shortcut. Spread its spec into your Annotation.Root() and add your own fields.
import { Annotation, MessagesAnnotation } from "@langchain/langgraph"
// MessagesAnnotation already has messages with a reducer
// Spread its spec and add your own fields
const StateAnnotation = Annotation.Root({
...MessagesAnnotation.spec,
currentAgent: Annotation<string>,
iterationCount: Annotation<number>({
reducer: (prev, next) => next,
default: () => 0,
}),
})Writing nodes
Nodes are async functions. They receive the full state and return an object with only the keys that changed. LangGraph.js merges the returned object into the existing state using the configured reducers.
import { StateGraph, START, END } from "@langchain/langgraph"
async function classify(state: typeof StateAnnotation.State) {
// Read from state, return partial updates
const lastMsg = state.messages[state.messages.length - 1]
const intent = await model.invoke([
{ role: "system", content: "Classify as 'question' or 'task'." },
lastMsg,
])
return { currentStep: intent.content }
}
async function answer(state: typeof StateAnnotation.State) {
const response = await model.invoke(state.messages)
return { messages: [response] }
}
async function execute(state: typeof StateAnnotation.State) {
const response = await model.invoke([
{ role: "system", content: "Execute the requested task." },
...state.messages,
])
return { messages: [response] }
}Connecting nodes with edges
Edges define how execution flows through the graph. Use addEdge for fixed transitions and addConditionalEdges for dynamic routing. The routing function receives the current state and returns the name of the next node.
const graph = new StateGraph(StateAnnotation)
// Add nodes
.addNode("classify", classify)
.addNode("answer", answer)
.addNode("execute", execute)
// Entry point
.addEdge(START, "classify")
// Conditional edge: route based on classification
.addConditionalEdges("classify", (state) => {
if (state.currentStep === "question") return "answer"
return "execute"
})
// Both endpoints go to END
.addEdge("answer", END)
.addEdge("execute", END)
const app = graph.compile()Best practices
Keep state flat
Avoid deeply nested state objects. Flat state schemas are easier to update, debug, and serialize. If you need complex data, store it as a JSON string or use a separate store.
Use MessagesAnnotation for message lists
If your graph works with message lists, spread MessagesAnnotation.spec into your state. It handles appending and deduplication automatically.
Return only what changed
Nodes should return an object with only the keys that changed. Returning the full state works but is wasteful and can cause issues with reducers.
Name your routing functions clearly
Conditional edge routing functions should have descriptive names or inline comments. This makes the graph structure easier to understand when debugging.
Use typed state parameters
Type your node parameters as typeof StateAnnotation.State. This gives you full TypeScript autocomplete and catches type errors at compile time.
FAQ
State management questions
Deploy your graph to production
Built your graph? Deploy it with a single command.