| name | langgraph-state-patterns |
| description | Manage LangGraph state: Annotation.Root with custom reducers (concatenate arrays, keep latest, deduplicate by ID, merge objects, conditional logic). Prefer flat state, use discriminated unions for variants. Validate with Zod guards. Version state for migrations. Use when building LangGraph agents. |
LangGraph State Patterns
Document Type: Architecture Guidelines for LangGraph-based agents.
Core Concept: Annotation.Root
Basic Pattern
import { Annotation } from '@langchain/langgraph';
export const StateAnnotation = Annotation.Root({
userId: Annotation<number>(),
messages: Annotation<BaseMessage[]>({
reducer: (x, y) => (x || []).concat(y || []),
}),
error: Annotation<ErrorState | undefined>({
reducer: (x, y) => y || x,
}),
});
export type State = typeof StateAnnotation.State;
Key Principles
- Immutable Updates: Nodes return partial state, LangGraph merges
- Custom Reducers: Control how new values merge with existing values
- Type Safety: TypeScript types generated from
Annotation.Root
- Selective Updates: Nodes only return fields they modify
- Default Reducer: Last write wins (no reducer specified)
Reducer Patterns
Pattern 1: Array Concatenation (Messages)
Use Case: Append new items to array without replacing
messages: Annotation<BaseMessage[]>({
reducer: (x, y) => (x || []).concat(y || []),
}),
Example:
return { messages: [msg3, msg4] };
Pattern 2: Latest Wins (Error State)
Use Case: Replace with latest value, keep previous if new is undefined
error: Annotation<ErrorState | undefined>({
reducer: (x, y) => y || x,
}),
Example:
return { error: { nodeName: 'foo', message: 'Failed' } };
return {};
Pattern 3: Last N Items (History)
Use Case: Keep bounded history (e.g., last 10 items)
researchHistory: Annotation<ResearchHistory[]>({
reducer: (x, y) => [...(x || []), ...(y || [])].slice(-10),
}),
Pattern 4: Object Merge (Metadata)
Use Case: Merge object properties, not replace entire object
metadata: Annotation<Record<string, any>>({
reducer: (x, y) => ({ ...(x || {}), ...(y || {}) }),
}),
Example:
return { metadata: { key2: 'updated', key3: 'new' } };
Pattern 5: Array Deduplication (Sources)
Use Case: Merge arrays and remove duplicates by ID
sources: Annotation<Source[]>({
reducer: (x, y) => {
const existing = x || [];
const incoming = y || [];
const combined = [...existing, ...incoming];
const seen = new Set<string>();
return combined.filter(source => {
if (seen.has(source.id)) return false;
seen.add(source.id);
return true;
});
},
}),
Pattern 6: Conditional Update (Facts)
Use Case: Only update if new value is better
facts: Annotation<Fact[]>({
reducer: (x, y) => {
if (!y || y.length === 0) return x || [];
if (!x || x.length === 0) return y;
return y.every(newFact =>
!x.some(oldFact => oldFact.id === newFact.id && oldFact.confidence > newFact.confidence)
) ? y : x;
},
}),
Pattern 7: Multi-Lesson State (Record Indexing)
Use Case: Manage state for multiple parallel items
lessonStates: Annotation<Record<string, LessonState>>({
reducer: (x, y) => ({ ...(x || {}), ...(y || {}) }),
}),
currentLessonId: Annotation<string | undefined>(),
async function generateContentNode(state: MnemonaState): Promise<Partial<MnemonaState>> {
const lessonId = state.currentLessonId!;
const lessonState = state.lessonStates[lessonId];
const content = await generateContent(lessonState);
return {
lessonStates: {
[lessonId]: {
...lessonState,
content,
status: 'content_generated',
},
},
};
}
State Structure Patterns
Pattern 1: Flat State (Preferred)
export const StateAnnotation = Annotation.Root({
userId: Annotation<number>(),
query: Annotation<string>(),
mode: Annotation<'fast' | 'deep'>(),
sources: Annotation<Source[]>(),
facts: Annotation<Fact[]>(),
confidence: Annotation<number>(),
result: Annotation<string>(),
error: Annotation<ErrorState | undefined>(),
});
Benefits:
- Simple access:
state.userId not state.input.userId
- Easy to update:
return { confidence: 0.85 }
- Clear field ownership
- TypeScript autocomplete works well
Pattern 2: Grouped State (When Necessary)
Use Case: Logical grouping of related fields
export const StateAnnotation = Annotation.Root({
input: Annotation<{
userId: number;
query: string;
mode: 'fast' | 'deep';
}>(),
config: Annotation<{
chunkSizeTokens: number;
maxSourcesAfterRerank: number;
targetFactCount: number;
}>(),
output: Annotation<{
facts: Fact[];
coverage_score: number;
overall_confidence: number;
}>(),
});
Pattern 3: Discriminated Union (Complex State Variants)
Use Case: State has mutually exclusive modes
type FastModeState = {
mode: 'fast';
maxSources: 12;
targetFacts: 15;
timeout: 30000;
};
type DeepModeState = {
mode: 'deep';
maxSources: 40;
targetFacts: 25;
timeout: 300000;
hypothesisDriven: boolean;
};
export const StateAnnotation = Annotation.Root({
modeConfig: Annotation<FastModeState | DeepModeState>(),
});
function someNode(state: State): Partial<State> {
if (state.modeConfig.mode === 'fast') {
const timeout = state.modeConfig.timeout;
} else {
const hypothesis = state.modeConfig.hypothesisDriven;
}
}
State Evolution Patterns
Pattern 1: Pipeline Transformation
Use Case: State evolves through linear stages
{ rawSources: [...] }
{ rawSources: [...], sources: [...] }
{ sources: [...], chunks: [...] }
{ sources: [...], chunks: [...], facts: [...] }
{ facts: [...], coverage_score: 0.85, overall_confidence: 0.78 }
Pattern 2: Iterative Refinement
Use Case: State cycles through refinement loop
{ content: {...}, qualityScore: 0.65, iterationCount: 1, feedback: ['Improve clarity'] }
{ content: {...}, qualityScore: 0.75, iterationCount: 2, feedback: ['Needs more engagement'] }
{ content: {...}, qualityScore: 0.82, iterationCount: 3, feedback: [], status: 'approved' }
Pattern 3: Aggregation (Multi-Source)
Use Case: Combine results from parallel nodes
{ query: 'AI safety' }
{ query: 'AI safety', kbSources: [...], webSources: [...] }
{ query: 'AI safety', kbSources: [...], webSources: [...], allSources: [...] }
State Validation Patterns
Pattern 1: Runtime Validation with Zod
import { z } from 'zod';
const StateSchema = z.object({
userId: z.number().positive(),
query: z.string().min(1),
mode: z.enum(['fast', 'deep']),
sources: z.array(
z.object({
id: z.string(),
content: z.string(),
credibility: z.number().min(0).max(1),
}),
),
});
function validateNode(state: State): Partial<State> {
try {
StateSchema.parse(state);
return { validationPassed: true };
} catch (error) {
return {
error: {
nodeName: 'validate',
message: `Invalid state: ${error.message}`,
},
};
}
}
Pattern 2: Assertion Helpers
function assertDefined<T>(value: T | undefined, fieldName: string): asserts value is T {
if (value === undefined) {
throw new Error(`Required field ${fieldName} is undefined`);
}
}
function someNode(state: State): Partial<State> {
assertDefined(state.sources, 'sources');
assertDefined(state.query, 'query');
}
Pattern 3: State Guards
function hasRequiredFields(state: State): state is State & { sources: Source[]; query: string } {
return state.sources !== undefined && state.sources.length > 0 && state.query !== undefined && state.query.length > 0;
}
function someNode(state: State): Partial<State> {
if (!hasRequiredFields(state)) {
return {
error: {
nodeName: 'some_node',
message: 'Missing required fields: sources or query',
},
};
}
}
State Migration Patterns
Pattern 1: Versioned State
export const StateAnnotation = Annotation.Root({
_version: Annotation<number>(),
});
function migrateState(state: any): State {
const version = state._version || 1;
if (version === 1) {
state = {
...state,
_version: 2,
newField: 'default value',
};
}
if (version === 2) {
state = {
...state,
_version: 3,
renamedField: state.oldField,
};
delete state.oldField;
}
return state;
}
Pattern 2: Backward Compatibility
async function nodeWithCompatibility(state: State): Promise<Partial<State>> {
const sources = state.sources || state.legacySources || [];
const processed = processSources(sources);
return { sources: processed };
}
State Debugging Patterns
Pattern 1: State Logging
function logStateSnapshot(state: State, nodeName: string): void {
logger.debug(`[${nodeName}] State snapshot:`, {
userId: state.userId,
mode: state.mode,
sourceCount: state.sources?.length || 0,
factCount: state.facts?.length || 0,
hasError: !!state.error,
iterationCount: state.iterationCount,
});
}
Pattern 2: State Diff Logging
function logStateDiff(before: State, after: Partial<State>, nodeName: string): void {
const changed = Object.keys(after);
logger.debug(`[${nodeName}] State changes:`, {
changedFields: changed,
details: changed.reduce((acc, key) => {
acc[key] = { before: before[key], after: after[key] };
return acc;
}, {}),
});
}
Pattern 3: State Invariant Checks
function checkInvariants(state: State, nodeName: string): void {
if (state.sourceCount !== state.sources?.length) {
logger.warn(`[${nodeName}] Invariant violation: sourceCount mismatch`);
}
if (state.iterationCount > state.maxIterations) {
logger.warn(`[${nodeName}] Invariant violation: exceeded max iterations`);
}
if (state.facts?.some((f) => f.evidence.length === 0)) {
logger.warn(`[${nodeName}] Invariant violation: facts without evidence`);
}
}
Field Naming Conventions
| Category | Examples | Convention |
|---|
| Input Fields | userId, query, mode | camelCase, descriptive |
| Working State | currentStep, processingIndex, iterationCount | Present tense |
| Output Fields | result, facts, coverage_score | Past tense or noun |
| Error Fields | error | Always optional |
| Boolean Flags | isComplete, hasError, shouldRetry | is/has/should prefix |
| Counters | iterationCount, maxIterations, sourceCount | count/max prefix |
Common Anti-Patterns
Anti-Pattern 1: Mutating State
function badNode(state: State): Partial<State> {
state.sources.push(newSource);
return { sources: state.sources };
}
function goodNode(state: State): Partial<State> {
return { sources: [...state.sources, newSource] };
}
Anti-Pattern 2: Over-Nesting
state.output.result.data.items[0].properties.value;
state.resultItems;
Anti-Pattern 3: Storing Functions in State
return { processor: (data) => data.map(transform) };
Anti-Pattern 4: Implicit Dependencies
function badNode(state: State): Partial<State> {
return { result: state.sources.length };
}
function goodNode(state: State): Partial<State> {
if (!state.sources) {
return { error: { message: 'Missing sources' } };
}
return { result: state.sources.length };
}
Testing State Patterns
State Builders
class StateBuilder {
private state: Partial<State> = {};
withUserId(userId: number): this {
this.state.userId = userId;
return this;
}
withQuery(query: string): this {
this.state.query = query;
return this;
}
build(): State {
return {
userId: 1,
query: 'default query',
mode: 'fast',
sources: [],
...this.state,
} as State;
}
}
const state = new StateBuilder().withUserId(123).withQuery('test query').build();
State Fixtures
export const testStates = {
minimal: (): State =>
({
userId: 1,
query: 'test',
mode: 'fast',
} as State),
withSources: (): State => ({
...testStates.minimal(),
sources: [
{ id: '1', content: 'Source 1', credibility: 0.8 },
{ id: '2', content: 'Source 2', credibility: 0.9 },
],
}),
withError: (): State => ({
...testStates.minimal(),
error: {
nodeName: 'test_node',
message: 'Test error',
timestamp: Date.now(),
},
}),
};
Best Practices Summary
Design Phase
- Start Flat: Default to flat state, nest only when necessary
- Plan Reducers: Identify which fields need custom merge logic
- Document Flow: Comment how state evolves through nodes
- Version State: Add
_version field for future migrations
- Type Everything: Full TypeScript types from
Annotation.Root
Implementation Phase
- Immutable Updates: Always return new objects/arrays
- Validate Early: Check state invariants at node entry
- Log Changes: Debug log state diffs after each node
- Handle Undefined: Defensive checks for optional fields
- Keep It Simple: Prefer simple reducers over complex logic
Testing Phase
- Use Builders: State builders for test setup
- Test Reducers: Unit test custom reducer logic
- Test Invariants: Verify state constraints hold
- Mock Minimally: Only mock what nodes actually access
- Snapshot State: Use snapshot testing for state evolution
References