Nodes, edges, and the surfaces they demand
Nodes do work, edges decide what happens next, and a conditional edge is a branch the user will see — reading the routing function is how you find out which screens the product actually needs.
You can read a React tree and know what the screen looks like. That skill transfers here more directly than you would expect, because a graph definition is also a declarative structure whose shape determines what a user experiences — and the mistake people make reading one for the first time is the same mistake juniors make reading a component tree: they read the nodes and skip the wiring.
The wiring is the part with UI consequences. A node tells you there is work happening. An edge tells you whether the user is on a rail or at a fork, and a fork the user can end up on either side of is a screen you have to design, whether or not anyone put it on the roadmap.
Nodes do the work, edges say what happens next
That phrasing is the docs’ own, and it is worth taking literally: “nodes do the work, edges tell what to do next.” A node is a function taking state and config, doing something, and returning a partial state update. An edge is either fixed or computed. The minimal shape, from the same page:
import { END, START, StateGraph, StateSchema } from "@langchain/langgraph";
import * as z from "zod";
const State = new StateSchema({ input: z.string(), results: z.string() });
const graph = new StateGraph(State)
.addNode("nodeA", (state) => ({ results: `Hello, ${state.input}!` }))
.addEdge(START, "nodeA")
.addEdge("nodeA", END)
.compile();START and END are real exported constants, not strings you invent. The builder methods chain, and that matters practically: each call widens the union of known node names, which is what makes the string in addEdge("nodeA", END) type-checked rather than a hopeful literal. Split the chain into separate statements and you lose that. When you are reading somebody else’s graph, a long unbroken chain is a good sign; a pile of loose builder.addNode(...) statements is a place typos live.
The source is LangChain’s own documentation for LangChain’s own framework — vendor documenting its own product — so read the API as fact and the “why you want this” framing as marketing. It is also moving fast enough that a code sample from a 2025-era blog post will not compile: the state-definition API in that snippet, StateSchema, replaced the older Annotation.Root as the documented default in LangGraph v1.1.0. That gets its own treatment in the state-as-contract lesson.
Conditional edges are branches your user will feel
A normal edge is a rail: after this node, always that node. A conditional edge hands routing to a function, and the function’s return value decides where execution goes. The documented form:
graph.addConditionalEdges("nodeA", routingFunction, {
true: "nodeB",
false: "nodeC",
});The third argument is optional. When present it is a path map — either a record translating the router’s return values into node names, as above, or a plain array of the possible destinations when the router already returns real node names. Its presence is a gift when you are reading unfamiliar code: it is a written-down list of every place execution can go from here, which is to say a written-down list of every branch the user can end up in.
When it is absent, you have to read the routing function body and enumerate the returns yourself. Do that anyway even when the map is there, because the map tells you the destinations and the function tells you the conditions, and the condition is what the UI has to explain. “This document went to manual review” is a destination. “This document went to manual review because confidence on three fields fell below the threshold” is a condition, and it is the only one of the two a broker can act on.
Where people get burned
The failure mode here is designing for the happy path because the happy path is the one the diagram draws left to right. A conditional edge with three destinations is three end states, and two of them will be under-specified in every design review you attend. Enumerate them from the code, not from the ticket — the ticket was written by somebody reading the same diagram.
Two more routing constructs worth recognizing
Beyond the plain conditional edge, two constructs show up constantly and both change what you build.
Command returned from a node combines a state update and a routing decision in one return value:
import { Command } from "@langchain/langgraph";
graph.addNode("myNode", (state) => {
return new Command({ update: { foo: "bar" }, goto: "myOtherNode" });
});This is the one that hides branches from you. The routing is inside the node body rather than declared on an edge, so a graph diagram will not show it and a skim will miss it. The convention that saves you is ends: a node whose routing is done by Command can declare its destinations at registration time, as builder.addNode("myNode", myNode, { ends: [ "myOtherNode", END] }). When you see ends on a node, that is your branch list. When you don’t, grep the node body for goto.
Send fans out to n copies of a node with different payloads, returned from a router:
import { Send } from "@langchain/langgraph";
graph.addConditionalEdges("nodeA", (state) => {
return state.subjects.map((subject) => new Send("generateJoke", { subject }));
});Recognize this on sight, because it is the single biggest UI signal in a graph definition. Send means the number of parallel units of work is determined at runtime by the data. There is no fixed step count to render. Everything you know about progress indicators applies differently: you cannot draw a five-step stepper for a run whose step count is a function of how many pages the uploaded document turned out to have.
And recall from the superstep lesson that nodes running in parallel are part of the same superstep, so a fanned-out batch arrives as concurrent work under one boundary rather than as a sequence. A list of live rows with independent states is usually the honest surface; a single bar is usually a lie.
Reading a node for its surface
Now the mechanical part. For each node in a graph, four questions, in this order:
- How long does it run? A node that calls a model or a tool is seconds to tens of seconds and needs something on screen. A node that reshapes state is milliseconds and needs nothing — rendering a step for it is noise that makes the real waits harder to read.
- What does it put into state? That is the only thing it can possibly give you to render. If a node’s update is a field nothing displays, its surface is at most a label.
- Can it fail, and what happens then? Node registration takes options including a
retryPolicy, atimeoutand anerrorHandlerthat runs once retries are exhausted. A node with a retry policy will visibly stall and recover, and a UI that renders the first failure as terminal will be wrong; a node with an error handler has a designed failure path you should find and render. - Does it stop for a human? If the body calls
interrupt, this is not a progress surface at all. It is a decision surface, and it is the most expensive screen in the product.
Answer those four for every node and you have the surface inventory. It is deliberately boring. Boring is what survives ninety codebases.
Check your recall
Answer from memory — no scrolling back.
Hands on
Turn the parser graph into a surface inventory
Done when: Every node in ARTIFACT.md’s node/edge table has an answer to all four questions, and the “branches the user will experience” list is derived from routing code — with each entry naming its condition, not just its destination.
- Take the node rows you sketched in
ARTIFACT.mdfor the parser. Add the routing: which edges are fixed rails, and which are conditional. There should be at least one conditional edge, because a document parser that never routes differently for a low-confidence extraction is not modelling the problem. - For each conditional edge, write both halves: the destinations and the condition that sends a run down each. Put those in the branches the user will experience list. A branch whose condition you cannot state in one sentence is a branch you cannot write copy for.
- Run the four questions over every node and fill in the UI surface while running column. Be willing to write “nothing” — a state-reshaping node that finishes in ten milliseconds should not get a step in a stepper, and noticing that is the skill.
- Mark the terminal column honestly. Count the distinct ways a run can end, including the failure routes. If that count is larger than the number of end screens you would have designed from the ticket, write the difference down — that gap is the entire argument for reading the graph first.
- Bring the table into the chat. I will pick the node with the emptiest row and ask what a user sees for the eleven seconds it is running.
What this does not cover
Everything above treats state as an opaque blob that nodes read and write. That is enough to find the branches, and not nearly enough to design the screens: whether a channel accumulates or overwrites decides whether you are rendering a transcript or a field, and a node’s return type is not the same type as the state it lands in. That is the state-as-contract lesson, and it is the one that changes your component choices.
It also says nothing about what any of this looks like from outside while it is happening. Knowing a node is slow does not tell you what the runtime will actually hand your client during those eleven seconds — the stream-modes lesson covers that, and the answer is more varied than you would guess. And the interrupt case, flagged here as the fourth question, is deferred on purpose to the interrupt module, where a pause gets treated as the primitive it is rather than as a node with an unusual body.
Read this next — primary source
Graph APILangChain — docs.langchain.com, JavaScript docs, fetched 2026-09-02. Vendor documenting its own product
This lesson takes the routing constructs and reads them as an interface spec. The page has a lot more surface than that: per-node retry, cache and timeout policies, error handlers, per-node input schemas, deferred nodes, subgraphs, and the recursion limit. Every one of those is something you will eventually find in a colleague’s graph and have to design an affordance for, and the page is the only place they are all listed together.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.