DEV Community

Cover image for LangGraph.js in Practice: Nodes, Edges, and State That Actually Persists
Gabriel Anhaia
Gabriel Anhaia

Posted on

LangGraph.js in Practice: Nodes, Edges, and State That Actually Persists


Most LangGraph material is written in Python and translated in your
head. The concepts carry over; the TypeScript does not, and the place
people lose an afternoon is the state definition — because that is
where LangGraph.js does something unusual to get you generic types.

Nodes and edges are the easy part. State is the API.

State is a schema, not an interface

You do not declare a type and hand it over. You build an annotation
object, and the graph derives its types from that.

import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
import type { BaseMessage } from "@langchain/core/messages";
import { messagesStateReducer } from "@langchain/langgraph";

const State = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer,
    default: () => [],
  }),
  question: Annotation<string>(),
  toolCalls: Annotation<number>({
    reducer: (current, update) => current + update,
    default: () => 0,
  }),
});

type StateT = typeof State.State;
Enter fullscreen mode Exit fullscreen mode

typeof State.State is the line to remember. That is your state type,
and it is what every node signature refers to. Writing a separate
interface AgentState next to this is the most common mistake — the
two drift, and the compiler only checks one of them.

Reducers decide what a return value means

This is the concept that has no equivalent in a plain agent loop.

A node returns a partial state. The reducer for each channel decides
how that partial merges with what is already there.

No reducer means replace:

question: Annotation<string>(),
// node returns { question: "x" } → state.question is now "x"
Enter fullscreen mode Exit fullscreen mode

A reducer means combine:

toolCalls: Annotation<number>({
  reducer: (current, update) => current + update,
  default: () => 0,
}),
// node returns { toolCalls: 1 } → state.toolCalls increments
Enter fullscreen mode Exit fullscreen mode

messagesStateReducer is the built-in for conversations: it appends,
and it handles message ids so an update to an existing message
replaces rather than duplicates it.

Getting this wrong produces a specific and confusing bug. Declare
messages without a reducer, and every node that returns messages
replaces the history instead of appending. The graph runs, no error
appears, and the model behaves as if it has amnesia between nodes.

If you find yourself writing return { messages: [...state.messages,
newMsg] }
inside a node, the reducer is missing. That spread is the
reducer's job.

Nodes are functions of state

async function retrieve(state: StateT) {
  const docs = await search(state.question);
  return { context: docs };
}

async function answer(state: StateT) {
  const res = await model.invoke([
    new SystemMessage(render(state.context)),
    ...state.messages,
  ]);
  return { messages: [res], toolCalls: res.tool_calls?.length ?? 0 };
}
Enter fullscreen mode Exit fullscreen mode

Take state, return a partial. The partial is typed — return a key
that is not in the annotation and it does not compile, which is most
of the value of using TypeScript here at all.

Wiring, and the type that surprises people

const workflow = new StateGraph(State)
  .addNode("retrieve", retrieve)
  .addNode("answer", answer)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "answer")
  .addEdge("answer", END);
Enter fullscreen mode Exit fullscreen mode

Chain the calls. Do not split them:

// loses type information about registered node names
const g = new StateGraph(State);
g.addNode("retrieve", retrieve);
g.addEdge(START, "retrieve");
Enter fullscreen mode Exit fullscreen mode

addNode returns a graph type parameterised by the node names added
so far. Chaining accumulates that; assigning to a const and calling
methods separately throws it away, and you lose compile-time checking
that an edge points at a node that exists.

That is worth internalising, because a typo in an edge target is
otherwise a runtime error deep in a graph run.

How chained addNode calls accumulate node names into the graph's type parameter.

Conditional edges

Branching is a function from state to the next node name.

function route(state: StateT): "tools" | "answer" | typeof END {
  const last = state.messages.at(-1);
  if (last?.tool_calls?.length) return "tools";
  if (state.toolCalls > 10) return END;
  return "answer";
}

const workflow = new StateGraph(State)
  .addNode("agent", agent)
  .addNode("tools", tools)
  .addNode("answer", answer)
  .addEdge(START, "agent")
  .addConditionalEdges("agent", route)
  .addEdge("tools", "agent")
  .addEdge("answer", END);
Enter fullscreen mode Exit fullscreen mode

Annotate the router's return type explicitly. Without the annotation
TypeScript widens it to string, and you lose the check that every
branch names a real node.

The router is a pure function of state, which makes it the most
testable part of the whole graph:

it("routes to tools when a tool call is pending", () => {
  expect(route({ ...base, messages: [withToolCall] })).toBe("tools");
});
Enter fullscreen mode Exit fullscreen mode

No model, no network. Routing logic is where agent bugs live, and
this is the one part you can unit test properly.

The checkpointer is the reason to be here

Everything above is expressible as a while loop. This is not.

import { MemorySaver } from "@langchain/langgraph";

const checkpointer = new MemorySaver();
const graph = workflow.compile({ checkpointer });

const config = { configurable: { thread_id: "conv-42" } };
await graph.invoke({ question: "How do I rotate a key?" }, config);
Enter fullscreen mode Exit fullscreen mode

The graph writes a checkpoint after every node. thread_id is the
key. Invoke again with the same thread_id and it resumes from the
last checkpoint with full state.

MemorySaver is in-process and dies with the process — fine for
tests, useless for the thing you actually wanted. For real
persistence use a durable saver backed by Postgres or SQLite from
@langchain/langgraph-checkpoint-*, and treat the checkpoint table as
production data: it is your agent's memory, and it grows.

Two operational facts that follow. Checkpoints accumulate per thread,
so you need a retention policy. And anything in state is serialised —
put a class instance with methods in there and it comes back as a
plain object.

// survives a checkpoint round trip
type Doc = { id: string; text: string; score: number };

// does not — methods are gone after deserialisation
class Doc { constructor(public id: string) {} render() { /* ... */ } }
Enter fullscreen mode Exit fullscreen mode

Keep state as plain data.

State channels merging through reducers and being written to a checkpoint after each node.

The newer Zod-based schema

Current docs also show a StateSchema API using Zod:

import { StateGraph, StateSchema, ReducedValue } from "@langchain/langgraph";
import { z } from "zod/v4";

const State = new StateSchema({
  foo: z.string(),
  bar: new ReducedValue(
    z.array(z.string()).default(() => []),
    { reducer: (x, y) => x.concat(y) },
  ),
});
Enter fullscreen mode Exit fullscreen mode

Same model — channels, reducers, defaults — expressed through Zod,
which is appealing if your codebase already validates with Zod.

Annotation.Root remains what most existing code and examples use.
Pick one per project rather than mixing, and check which your
installed version supports before committing to it.

What actually goes wrong

Three failures account for most of the lost time.

Missing reducer on messages. History resets between nodes. No
error, model appears to forget.

Unchained builder calls. Edge typos become runtime errors instead
of compile errors.

MemorySaver in production. Everything works until a deploy, and
every in-flight conversation is gone.

None of them throw at the point of the mistake, which is why they
cost an afternoon rather than a minute.


If this was useful

AI That Plans covers
LangGraph.js properly — state design, reducers, routing, durable
checkpointers, and the multi-agent patterns built on top of them.

AI That Plans — Stateful AI Agents with LangGraph.js

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)