Repository: assistant-ui/assistant-ui (2026-04-20T15:03:37Z)

Issue:
Feature request: expose `useLangChainState<T>(key)` in `@assistant-ui/react-langchain`
### Problem statement

### Summary

`@assistant-ui/react-langchain` (introduced in #3807) already wraps `useStream` from `@langchain/react` and exposes `useLangChainInterruptState` / `useLangChainSubmit`. It would be great to extend the same pattern with a generic hook for reading arbitrary LangGraph custom state keys:

```ts
const todos = useLangChainState<Todo[]>('todos')
const files = useLangChainState<Record<string, string>>('files')
```

This unblocks first-class rendering of structured agent state (deepagents `todos`/`files`, generative-UI artifacts, plans, scratch memory) without having to reconstruct it from tool-call streams.

### Motivation

The canonical LangGraph pattern for structured UI state is custom state keys, not tool-call args. LangChain's own docs demonstrate this for every supported framework — React, Vue, Svelte, Angular — via `useStream().values.<key>`:

- https://docs.langchain.com/oss/python/deepagents/frontend/todo-list

With deepagents specifically, the `write_todos` middleware emits both a tool call AND updates `state.todos`. Today, assistant-ui consumers can only react to the tool-call stream, which forces us to:

- Pair each `write_todos` tool_call with its `tool_result` FIFO to know when a plan is "committed"
- Keep a ref-based cache to prevent mid-stream unmount flicker
- Fall back to a previous call's args while a new one is streaming empty JSON
- Duplicate parsing logic across every consumer

None of that would exist if we could read `state.todos` directly — it's a single current value that updates in place. Exactly the problem `useStream().values` was designed to solve upstream.

### Proposal

Extend the existing hook family:

```ts
// Already exists
useLangChainInterruptState()

// Proposed
useLangChainState<T>(key: string): T | undefined
useLangChainState<T>(key: string, defaultValue: T): T
```

Semantics mirror LangChain's `useStream().values[key]` — returns the latest value for the given custom state key on the current thread, updates when the stream emits new values.

### Why this specific shape

- **Minimal surface**: one hook, same pattern as `useLangChainInterruptState`.
- **Framework-neutral**: not tied to deepagents — works for any LangGraph graph that defines custom state keys (todos, files, artifacts, plans, anything).
- **No duplicate `useStream`**: avoids asking consumers to open a second connection alongside `useStreamRuntime`, which would be confusing and wasteful.

### Alternatives considered

- **Render tool-call parts and reconstruct state**: fragile (streaming partials, unmount flicker, FIFO pairing with results) — current state of the art, and the reason I'm filing this.
- **Let users call `useStream` separately**: duplicates the subscription and defeats the point of the runtime wrapper.
- **Expose the whole `useStream` return value via a context**: could work, but a typed `useLangChainState<T>(key)` is friendlier and matches the precedent set by `useLangChainInterruptState`.

### References

- #3807 — `@assistant-ui/react-langchain` package (merged April 2026)
- #2513 — subgraph state streaming (merged September 2025)
- LangChain deepagents todo-list docs (React/Vue/Svelte/Angular reference implementations): https://docs.langchain.com/oss/python/deepagents/frontend/todo-list


### Proposed solution

_No response_

### Area

None

### Alternatives considered

_No response_

### Additional context

_No response_


Discussion:

Patch:
diff --git a/.changeset/feat-react-langchain-use-state-hook.md b/.changeset/feat-react-langchain-use-state-hook.md
new file mode 100644
index 0000000000..d918205611
--- /dev/null
+++ b/.changeset/feat-react-langchain-use-state-hook.md
@@ -0,0 +1,5 @@
+---
+"@assistant-ui/react-langchain": patch
+---
+
+feat: add `useLangChainState<T>(key)` hook to read arbitrary LangGraph custom state keys (e.g. `todos`, `files`) from the current thread, mirroring upstream `useStream().values[key]`.
diff --git a/apps/docs/content/docs/runtimes/langchain/comparison.mdx b/apps/docs/content/docs/runtimes/langchain/comparison.mdx
new file mode 100644
index 0000000000..fae66a377c
--- /dev/null
+++ b/apps/docs/content/docs/runtimes/langchain/comparison.mdx
@@ -0,0 +1,60 @@
+---
+title: Comparison with `react-langgraph`
+description: How `@assistant-ui/react-langchain` differs from `@assistant-ui/react-langgraph`.
+---
+
+Both packages connect assistant-ui to LangGraph backends. They are **independent adapters for different upstream libraries** — one is not a successor to the other.
+
+| Aspect                         | `@assistant-ui/react-langgraph`                              | `@assistant-ui/react-langchain`                       |
+| ------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------- |
+| Wraps                          | `@langchain/langgraph-sdk` (raw SDK)                         | `@langchain/react` (`useStream` hook)                 |
+| Age                            | Sept 2024 onward                                             | April 2026 onward                                     |
+| Version                        | `0.13.x`                                                     | `0.0.x`                                               |
+| Lines of source                | ~7,500                                                       | ~600                                                  |
+| Built on                       | `useExternalStoreRuntime`                                    | `useExternalStoreRuntime`                             |
+| `create-assistant-ui` template | `-t langgraph` ships this package                            | No template yet                                       |
+
+## Feature coverage
+
+| Feature                                 | `react-langgraph`                | `react-langchain`                   |
+| --------------------------------------- | -------------------------------- | ----------------------------------- |
+| Stream messages                         | ✅ `useLangGraphRuntime`          | ✅ `useStreamRuntime`                |
+| Interrupt state                         | ✅ `useLangGraphInterruptState`   | ✅ `useLangChainInterruptState`      |
+| Send raw state update / resume command  | ✅ `useLangGraphSendCommand`      | ✅ `useLangChainSubmit`              |
+| Read arbitrary custom state key         | ❌                                | ✅ `useLangChainState<T>(key)`       |
+| Per-message metadata (`messages-tuple`) | ✅ `useLangGraphMessageMetadata`  | ❌ not exposed                       |
+| Generative UI messages (LangSmith)      | ✅ `useLangGraphUIMessages`       | ❌ not exposed                       |
+| Subgraph / namespaced stream events     | ✅ via `eventHandlers`            | ❌ not exposed                       |
+| End-to-end cancellation primitive       | ✅ `unstable_createLangGraphStream` | ❌ not exposed                     |
+| Message accumulator utility             | ✅ `LangGraphMessageAccumulator`  | ❌ not exposed                       |
+| Cloud thread persistence                | ✅ `cloud` option                 | ✅ `cloud` option                    |
+
+`react-langchain` is the newer, thinner wrapper — it delegates to the upstream `useStream` hook rather than re-implementing the stream plumbing. That is why its footprint is smaller and its surface area is narrower today. Features that exist in `react-langgraph` but not `react-langchain` are absent because they have not yet been ported, not because they are deprecated.
+
+## Choosing between them
+
+Use `@assistant-ui/react-langgraph` when:
+
+- You are scaffolding via `npx create-assistant-ui -t langgraph`.
+- You want the broader feature set today (subgraph events, UI messages, message metadata, cancellation).
+- You prefer integrating with `@langchain/langgraph-sdk` directly.
+
+Use `@assistant-ui/react-langchain` when:
+
+- Your app already depends on `@langchain/react` and uses `useStream` elsewhere.
+- You want to read custom state keys (`todos`, `files`, plans, ...) with `useLangChainState<T>(key)` without reconstructing them from tool-call streams.
+- You prefer a thin wrapper that stays pinned to upstream behavior.
+
+## Hook name mapping
+
+If you are moving code between the two adapters, most hooks have a counterpart — but note the feature gaps above.
+
+| `react-langgraph`                  | `react-langchain`                   | Notes                                                       |
+| ---------------------------------- | ----------------------------------- | ----------------------------------------------------------- |
+| `useLangGraphRuntime`              | `useStreamRuntime`                  | Options extend upstream `UseStreamOptions`; no `stream` / `create` / `load` to write. |
+| `useLangGraphInterruptState`       | `useLangChainInterruptState`        | Same return shape: `{ value?: unknown } \| undefined`.      |
+| `useLangGraphSendCommand`          | `useLangChainSubmit`                | `submit(values, { command })` replaces the dedicated hook.  |
+| `useLangGraphSend`                 | _(use `runtime.thread.append`)_     | No direct equivalent; send turns through the runtime.       |
+| `useLangGraphMessageMetadata`      | _(not available)_                   | Open an issue if you rely on this.                          |
+| `useLangGraphUIMessages`           | _(not available)_                   | Open an issue if you rely on this.                          |
+| _(none)_                           | `useLangChainState<T>(key)`         | New — reads any custom state key reactively.                |
diff --git a/apps/docs/content/docs/runtimes/langchain/index.mdx b/apps/docs/content/docs/runtimes/langchain/index.mdx
new file mode 100644
index 0000000000..d78b8854b6
--- /dev/null
+++ b/apps/docs/content/docs/runtimes/langchain/index.mdx
@@ -0,0 +1,210 @@
+---
+title: Getting Started
+description: Adapter for LangChain's `useStream` hook, exposed as an assistant-ui runtime.
+---
+
+`@assistant-ui/react-langchain` wraps [`useStream`](https://docs.langchain.com/oss/javascript/langgraph-sdk/react-stream) from `@langchain/react` and exposes it as an assistant-ui runtime. Use this package if you are already integrating your app with `@langchain/react` and want assistant-ui on top of the upstream hook.
+
+<Callout type="info">
+  assistant-ui ships two adapters for LangGraph backends:
+  - **[`@assistant-ui/react-langgraph`](/docs/runtimes/langgraph)** integrates with `@langchain/langgraph-sdk` directly and exposes features like subgraph events, UI messages, message metadata, and end-to-end cancellation.
+  - **`@assistant-ui/react-langchain`** (this page) wraps `@langchain/react`'s `useStream`. It is lighter-weight and stays aligned with upstream, but currently does not expose every `react-langgraph` feature.
+
+  See [the comparison doc](/docs/runtimes/langchain/comparison) for a feature gap table.
+</Callout>
+
+## Requirements
+
+You need a LangGraph Cloud API server. You can start a server locally via [LangGraph Studio](https://github.com/langchain-ai/langgraph-studio) or use [LangSmith](https://www.langchain.com/langsmith) for a hosted version.
+
+The state of the graph you are using must have a `messages` key with a list of LangChain-alike messages (or pass a custom `messagesKey`).
+
+## Installation
+
+<Steps>
+  <Step>
+
+### Install dependencies
+
+<InstallCommand npm={["@assistant-ui/react", "@assistant-ui/react-langchain", "@langchain/react", "@langchain/langgraph-sdk"]} />
+
+  </Step>
+  <Step>
+
+### Define a `MyAssistant` component
+
+```tsx title="@/components/MyAssistant.tsx"
+"use client";
+
+import { Thread } from "@/components/assistant-ui/thread";
+import { AssistantRuntimeProvider } from "@assistant-ui/react";
+import { useStreamRuntime } from "@assistant-ui/react-langchain";
+
+export function MyAssistant() {
+  const runtime = useStreamRuntime({
+    assistantId: process.env["NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID"]!,
+    apiUrl: process.env["NEXT_PUBLIC_LANGGRAPH_API_URL"],
+  });
+
+  return (
+    <AssistantRuntimeProvider runtime={runtime}>
+      <Thread />
+    </AssistantRuntimeProvider>
+  );
+}
+```
+
+  </Step>
+  <Step>
+
+### Use the `MyAssistant` component
+
+```tsx title="@/app/page.tsx"
+import { MyAssistant } from "@/components/MyAssistant";
+
+export default function Home() {
+  return (
+    <main className="h-dvh">
+      <MyAssistant />
+    </main>
+  );
+}
+```
+
+  </Step>
+  <Step>
+
+### Set environment variables
+
+Create a `.env.local` file in your project with the following variables:
+
+```sh
+NEXT_PUBLIC_LANGGRAPH_API_URL=http://localhost:2024
+NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID=your_graph_id
+```
+
+  </Step>
+  <Step>
+
+### Set up UI components
+
+Follow the [UI Components](/docs/ui/thread) guide to set up the UI components.
+
+  </Step>
+</Steps>
+
+## `useStreamRuntime` options
+
+`useStreamRuntime` accepts every option [`useStream`](https://docs.langchain.com/oss/javascript/langgraph-sdk/react-stream) from `@langchain/react` does, plus three assistant-ui-specific fields:
+
+| Option        | Type                                           | Description                                                              |
+| ------------- | ---------------------------------------------- | ------------------------------------------------------------------------ |
+| `cloud`       | `AssistantCloud`                               | Optional — persists threads via assistant-cloud.                         |
+| `adapters`    | `{ attachments?, speech?, feedback? }`         | Optional — attachment, speech, and feedback adapters.                    |
+| `messagesKey` | `string`                                       | The state key that holds messages. Defaults to `"messages"`.             |
+
+## Reading custom state keys
+
+LangGraph agents often expose structured state beyond messages (plans, todos, scratch files, generative-UI artifacts). Read them directly with `useLangChainState`. It mirrors `useStream().values[key]` upstream and updates when the stream emits new state.
+
+```tsx
+import { useLangChainState } from "@assistant-ui/react-langchain";
+
+type Todo = { id: string; title: string; done: boolean };
+
+function TodoList() {
+  const todos = useLangChainState<Todo[]>("todos", []);
+
+  return (
+    <ul>
+      {todos.map((t) => (
+        <li key={t.id}>
+          {t.done ? "✓" : "○"} {t.title}
+        </li>
+      ))}
+    </ul>
+  );
+}
+```
+
+Signatures:
+
+```ts
+useLangChainState<T>(key: string): T | undefined;
+useLangChainState<T>(key: string, defaultValue: T): T;
+```
+
+This hook is especially useful with the [`deepagents`](https://docs.langchain.com/oss/python/deepagents) middleware, whose `write_todos` step updates `state.todos` alongside the tool-call stream. Reading the state key directly avoids reconstructing the list from partial tool-call args.
+
+<Callout type="info">
+  Added in v0.0.2 — see issue [#3862](https://github.com/assistant-ui/assistant-ui/issues/3862) for motivation.
+</Callout>
+
+## Interrupts
+
+LangGraph interrupts pause the graph and wait for client input. `useLangChainInterruptState` exposes the current interrupt; `useLangChainSubmit` resumes the graph with a raw state update.
+
+```tsx
+import {
+  useLangChainInterruptState,
+  useLangChainSubmit,
+} from "@assistant-ui/react-langchain";
+import { Command } from "@langchain/langgraph-sdk";
+
+function InterruptPrompt() {
+  const interrupt = useLangChainInterruptState();
+  const submit = useLangChainSubmit();
+
+  if (!interrupt) return null;
+
+  return (
+    <div>
+      <pre>{JSON.stringify(interrupt.value, null, 2)}</pre>
+      <button
+        onClick={() =>
+          submit(null, { command: new Command({ resume: "approved" }) })
+        }
+      >
+        Approve
+      </button>
+    </div>
+  );
+}
+```
+
+## Message conversion
+
+`convertLangChainBaseMessage` transforms a LangChain `BaseMessage` into an assistant-ui message. Use it when building a custom `ExternalStoreAdapter` that needs to consume LangChain messages outside of `useStreamRuntime`.
+
+```ts
+import { convertLangChainBaseMessage } from "@assistant-ui/react-langchain";
+```
+
+## Cloud persistence
+
+Pass an `AssistantCloud` instance to persist threads across sessions. The runtime automatically wires thread list management and resumes state from the cloud.
+
+```tsx
+import { AssistantCloud } from "assistant-cloud";
+import { useStreamRuntime } from "@assistant-ui/react-langchain";
+
+const cloud = new AssistantCloud({ baseUrl: "/api/cloud" });
+
+const runtime = useStreamRuntime({
+  cloud,
+  assistantId: "agent",
+  apiUrl: "http://localhost:2024",
+});
+```
+
+## Custom `messagesKey`
+
+If your graph stores messages under a non-default key, pass `messagesKey` so the runtime submits tool results and human turns to the correct state slot:
+
+```tsx
+const runtime = useStreamRuntime({
+  assistantId: "agent",
+  apiUrl: "http://localhost:2024",
+  messagesKey: "chat_messages",
+});
+```
diff --git a/apps/docs/content/docs/runtimes/langchain/meta.json b/apps/docs/content/docs/runtimes/langchain/meta.json
new file mode 100644
index 0000000000..37ae36693e
--- /dev/null
+++ b/apps/docs/content/docs/runtimes/langchain/meta.json
@@ -0,0 +1,4 @@
+{
+  "title": "LangChain useStream",
+  "pages": ["index", "comparison"]
+}
diff --git a/apps/docs/content/docs/runtimes/langgraph/index.mdx b/apps/docs/content/docs/runtimes/langgraph/index.mdx
index a9acc2fb27..34468105b2 100644
--- a/apps/docs/content/docs/runtimes/langgraph/index.mdx
+++ b/apps/docs/content/docs/runtimes/langgraph/index.mdx
@@ -3,6 +3,10 @@ title: Getting Started
 description: Connect to LangGraph Cloud API for agent workflows with streaming.
 ---
 
+<Callout type="info">
+  If you are already using `@langchain/react`'s `useStream` hook, the alternative [`@assistant-ui/react-langchain`](/docs/runtimes/langchain) adapter may fit better. `@assistant-ui/react-langgraph` (this page) integrates with `@langchain/langgraph-sdk` directly and has the broader feature set — subgraph events, UI messages, message metadata, end-to-end cancellation. See the [comparison](/docs/runtimes/langchain/comparison).
+</Callout>
+
 ## Requirements
 
 You need a LangGraph Cloud API server. You can start a server locally via [LangGraph Studio](https://github.com/langchain-ai/langgraph-studio) or use [LangSmith](https://www.langchain.com/langsmith) for a hosted version.
diff --git a/apps/docs/content/docs/runtimes/meta.json b/apps/docs/content/docs/runtimes/meta.json
index d849bdaee0..7f4cee876a 100644
--- a/apps/docs/content/docs/runtimes/meta.json
+++ b/apps/docs/content/docs/runtimes/meta.json
@@ -8,6 +8,7 @@
     "ai-sdk",
     "data-stream",
     "mastra",
+    "langchain",
     "langgraph",
     "google-adk",
     "a2a",
diff --git a/apps/docs/content/docs/runtimes/pick-a-runtime.mdx b/apps/docs/content/docs/runtimes/pick-a-runtime.mdx
index 5cb4cb834e..4290f87d73 100644
--- a/apps/docs/content/docs/runtimes/pick-a-runtime.mdx
+++ b/apps/docs/content/docs/runtimes/pick-a-runtime.mdx
@@ -11,7 +11,8 @@ Choosing the right runtime is crucial for your assistant-ui implementation. This
 graph TD
     A[What's your starting point?] --> B{Existing Framework?}
     B -->|Vercel AI SDK| C[Use AI SDK Integration]
-    B -->|LangGraph| D[Use LangGraph Runtime]
+    B -->|LangGraph via langgraph-sdk| D1[Use react-langgraph]
+    B -->|LangChain via @langchain/react useStream| D2[Use react-langchain]
     B -->|LangServe| E[Use LangServe Runtime]
     B -->|Mastra| F[Use Mastra Runtime]
     B -->|AG-UI Protocol| J[Use AG-UI Runtime]
@@ -55,9 +56,14 @@ For popular frameworks, we provide ready-to-use integrations built on top of our
   />
   <Card
     title="LangGraph"
-    description="For complex agent workflows with LangChain's graph framework"
+    description="Integrates with `@langchain/langgraph-sdk` directly. Broader feature set: subgraph events, UI messages, message metadata, cancellation."
     href="/docs/runtimes/langgraph"
   />
+  <Card
+    title="LangChain useStream"
+    description="Wraps `useStream` from `@langchain/react`. Lighter-weight, stays aligned with upstream. Fewer features today."
+    href="/docs/runtimes/langchain"
+  />
   <Card
     title="LangServe"
     description="For LangChain applications deployed with LangServe"
@@ -87,13 +93,14 @@ For popular frameworks, we provide ready-to-use integrations built on top of our
 The pre-built integrations (AI SDK, LangGraph, etc.) are **not separate runtime types**. They're convenient wrappers built on top of our core runtimes:
 
 - **AI SDK Integration** → Built on `LocalRuntime` with streaming adapter
-- **LangGraph Runtime** → Built on `LocalRuntime` with graph execution adapter
+- **LangGraph Runtime** → Built on `ExternalStoreRuntime`, integrates with `@langchain/langgraph-sdk`
+- **LangChain useStream Runtime** → Built on `ExternalStoreRuntime`, wraps `useStream` from `@langchain/react`
 - **LangServe Runtime** → Built on `LocalRuntime` with LangServe client adapter
 - **Mastra Runtime** → Built on `LocalRuntime` with workflow adapter
 - **AG-UI Runtime** → Built on `LocalRuntime` with AG-UI protocol adapter
 - **A2A Runtime** → Built on `LocalRuntime` with Agent-to-Agent protocol adapter
 
-This means you get all the benefits of `LocalRuntime` (automatic state management, built-in features) with zero configuration for your specific framework.
+This means pre-built integrations give you assistant-ui's features — state management, streaming, UI primitives — with zero configuration for your specific framework, regardless of whether the adapter happens to build on `LocalRuntime` or `ExternalStoreRuntime` internally. The list above tells you which core runtime each adapter uses.
 
 ### When to Use Pre-Built vs Core Runtimes
 
@@ -228,6 +235,7 @@ Explore our implementation examples:
    - [`LocalRuntime` Guide](/docs/runtimes/custom/local)
    - [`ExternalStoreRuntime` Guide](/docs/runtimes/custom/external-store)
    - [LangGraph Integration](/docs/runtimes/langgraph)
+   - [LangChain useStream Integration](/docs/runtimes/langchain)
 3. **Start with an example** from our [examples repository](https://github.com/assistant-ui/assistant-ui/tree/main/examples)
 4. **Add features progressively** using adapters
 5. **Consider Assistant Cloud** for production persistence
diff --git a/examples/with-langchain/README.md b/examples/with-langchain/README.md
new file mode 100644
index 0000000000..7d31e5b5f2
--- /dev/null
+++ b/examples/with-langchain/README.md
@@ -0,0 +1,47 @@
+# LangChain `useStream` Example
+
+Demonstrates `@assistant-ui/react-langchain`, which wraps `useStream` from `@langchain/react` and exposes it as an assistant-ui runtime.
+
+> assistant-ui also ships `@assistant-ui/react-langgraph`, which integrates with `@langchain/langgraph-sdk` directly and currently has a broader feature set. Pick the adapter that matches your upstream choice. See [the comparison](https://www.assistant-ui.com/docs/runtimes/langchain/comparison).
+
+## Quick Start
+
+```bash
+pnpm install
+```
+
+### Environment Variables
+
+Create `.env.local`:
+
+```sh
+NEXT_PUBLIC_LANGGRAPH_API_URL=http://localhost:2024
+NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID=your_graph_id
+```
+
+Start a local LangGraph server via [LangGraph Studio](https://github.com/langchain-ai/langgraph-studio) pointing at your graph, or use [LangSmith](https://www.langchain.com/langsmith) for a hosted deployment.
+
+### Run
+
+```bash
+pnpm dev
+```
+
+Open [http://localhost:3000](http://localhost:3000).
+
+## Key Features
+
+- `useStreamRuntime` — thin wrapper around `useStream` from `@langchain/react`.
+- `useLangChainState<T>(key)` — reads arbitrary custom state keys (demo uses `state.todos` in `TodosPanel`).
+- No hand-written stream generator, no `create` / `load` / `getCheckpointId` glue.
+
+## Files of interest
+
+- `app/MyRuntimeProvider.tsx` — entire runtime setup in ~15 lines.
+- `components/TodosPanel.tsx` — demonstrates `useLangChainState<Todo[]>("todos", [])`.
+
+## Related Documentation
+
+- [assistant-ui Documentation](https://www.assistant-ui.com/docs)
+- [LangChain useStream Integration](https://www.assistant-ui.com/docs/runtimes/langchain)
+- [react-langgraph vs react-langchain](https://www.assistant-ui.com/docs/runtimes/langchain/comparison)
diff --git a/examples/with-langchain/app/MyRuntimeProvider.tsx b/examples/with-langchain/app/MyRuntimeProvider.tsx
new file mode 100644
index 0000000000..fb956fc0f7
--- /dev/null
+++ b/examples/with-langchain/app/MyRuntimeProvider.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { AssistantRuntimeProvider } from "@assistant-ui/react";
+import { useStreamRuntime } from "@assistant-ui/react-langchain";
+
+export function MyRuntimeProvider({
+  children,
+}: Readonly<{
+  children: React.ReactNode;
+}>) {
+  const runtime = useStreamRuntime({
+    assistantId: process.env.NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID!,
+    apiUrl: process.env.NEXT_PUBLIC_LANGGRAPH_API_URL,
+  });
+
+  return (
+    <AssistantRuntimeProvider runtime={runtime}>
+      {children}
+    </AssistantRuntimeProvider>
+  );
+}
diff --git a/examples/with-langchain/app/globals.css b/examples/with-langchain/app/globals.css
new file mode 100644
index 0000000000..e0c5d68819
--- /dev/null
+++ b/examples/with-langchain/app/globals.css
@@ -0,0 +1,122 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@source "../../../packages/ui/src";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
+  --color-background: var(--background);
+  --color-foreground: var(--foreground);
+  --color-card: var(--card);
+  --color-card-foreground: var(--card-foreground);
+  --color-popover: var(--popover);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-primary: var(--primary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-secondary: var(--secondary);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-muted: var(--muted);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-accent: var(--accent);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-destructive: var(--destructive);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+  --color-chart-1: var(--chart-1);
+  --color-chart-2: var(--chart-2);
+  --color-chart-3: var(--chart-3);
+  --color-chart-4: var(--chart-4);
+  --color-chart-5: var(--chart-5);
+  --color-sidebar: var(--sidebar);
+  --color-sidebar-foreground: var(--sidebar-foreground);
+  --color-sidebar-primary: var(--sidebar-primary);
+  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+  --color-sidebar-accent: var(--sidebar-accent);
+  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+  --color-sidebar-border: var(--sidebar-border);
+  --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+  --radius: 0.625rem;
+  --background: oklch(1 0 0);
+  --foreground: oklch(0.141 0.005 285.823);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.141 0.005 285.823);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.141 0.005 285.823);
+  --primary: oklch(0.21 0.006 285.885);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.967 0.001 286.375);
+  --secondary-foreground: oklch(0.21 0.006 285.885);
+  --muted: oklch(0.967 0.001 286.375);
+  --muted-foreground: oklch(0.552 0.016 285.938);
+  --accent: oklch(0.967 0.001 286.375);
+  --accent-foreground: oklch(0.21 0.006 285.885);
+  --destructive: oklch(0.577 0.245 27.325);
+  --border: oklch(0.92 0.004 286.32);
+  --input: oklch(0.92 0.004 286.32);
+  --ring: oklch(0.705 0.015 286.067);
+  --chart-1: oklch(0.646 0.222 41.116);
+  --chart-2: oklch(0.6 0.118 184.704);
+  --chart-3: oklch(0.398 0.07 227.392);
+  --chart-4: oklch(0.828 0.189 84.429);
+  --chart-5: oklch(0.769 0.188 70.08);
+  --sidebar: oklch(0.985 0 0);
+  --sidebar-foreground: oklch(0.141 0.005 285.823);
+  --sidebar-primary: oklch(0.21 0.006 285.885);
+  --sidebar-primary-foreground: oklch(0.985 0 0);
+  --sidebar-accent: oklch(0.967 0.001 286.375);
+  --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+  --sidebar-border: oklch(0.92 0.004 286.32);
+  --sidebar-ring: oklch(0.705 0.015 286.067);
+}
+
+.dark {
+  --background: oklch(0.141 0.005 285.823);
+  --foreground: oklch(0.985 0 0);
+  --card: oklch(0.21 0.006 285.885);
+  --card-foreground: oklch(0.985 0 0);
+  --popover: oklch(0.21 0.006 285.885);
+  --popover-foreground: oklch(0.985 0 0);
+  --primary: oklch(0.92 0.004 286.32);
+  --primary-foreground: oklch(0.21 0.006 285.885);
+  --secondary: oklch(0.274 0.006 286.033);
+  --secondary-foreground: oklch(0.985 0 0);
+  --muted: oklch(0.274 0.006 286.033);
+  --muted-foreground: oklch(0.705 0.015 286.067);
+  --accent: oklch(0.274 0.006 286.033);
+  --accent-foreground: oklch(0.985 0 0);
+  --destructive: oklch(0.704 0.191 22.216);
+  --border: oklch(1 0 0 / 10%);
+  --input: oklch(1 0 0 / 15%);
+  --ring: oklch(0.552 0.016 285.938);
+  --chart-1: oklch(0.488 0.243 264.376);
+  --chart-2: oklch(0.696 0.17 162.48);
+  --chart-3: oklch(0.769 0.188 70.08);
+  --chart-4: oklch(0.627 0.265 303.9);
+  --chart-5: oklch(0.645 0.246 16.439);
+  --sidebar: oklch(0.21 0.006 285.885);
+  --sidebar-foreground: oklch(0.985 0 0);
+  --sidebar-primary: oklch(0.488 0.243 264.376);
+  --sidebar-primary-foreground: oklch(0.985 0 0);
+  --sidebar-accent: oklch(0.274 0.006 286.033);
+  --sidebar-accent-foreground: oklch(0.985 0 0);
+  --sidebar-border: oklch(1 0 0 / 10%);
+  --sidebar-ring: oklch(0.552 0.016 285.938);
+}
+
+@layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+  body {
+    @apply bg-background text-foreground;
+  }
+}
diff --git a/examples/with-langchain/app/layout.tsx b/examples/with-langchain/app/layout.tsx
new file mode 100644
index 0000000000..16c16f360d
--- /dev/null
+++ b/examples/with-langchain/app/layout.tsx
@@ -0,0 +1,23 @@
+import type { Metadata } from "next";
+import "./globals.css";
+import { MyRuntimeProvider } from "./MyRuntimeProvider";
+
+export const metadata: Metadata = {
+  title: "LangChain useStream Example",
+  description:
+    "Example using @assistant-ui/react-langchain with LangChain's useStream hook.",
+};
+
+export default function RootLayout({
+  children,
+}: Readonly<{
+  children: React.ReactNode;
+}>) {
+  return (
+    <MyRuntimeProvider>
+      <html lang="en">
+        <body className="h-dvh">{children}</body>
+      </html>
+    </MyRuntimeProvider>
+  );
+}
diff --git a/examples/with-langchain/app/page.tsx b/examples/with-langchain/app/page.tsx
new file mode 100644
index 0000000000..934a8f3c9f
--- /dev/null
+++ b/examples/with-langchain/app/page.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { Thread } from "@/components/assistant-ui/thread";
+import { TodosPanel } from "@/components/TodosPanel";
+
+export default function Home() {
+  return (
+    <div className="flex h-dvh">
+      <div className="flex-grow">
+        <Thread />
+      </div>
+      <aside className="w-80 overflow-y-auto border-l p-4">
+        <TodosPanel />
+      </aside>
+    </div>
+  );
+}
diff --git a/examples/with-langchain/components.json b/examples/with-langchain/components.json
new file mode 100644
index 0000000000..474f2a89dd
--- /dev/null
+++ b/examples/with-langchain/components.json
@@ -0,0 +1,24 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "app/globals.css",
+    "baseColor": "zinc",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "iconLibrary": "lucide",
+  "registries": {
+    "@assistant-ui": "https://r.assistant-ui.com/{name}.json"
+  }
+}
diff --git a/examples/with-langchain/components/TodosPanel.tsx b/examples/with-langchain/components/TodosPanel.tsx
new file mode 100644
index 0000000000..861fde153a
--- /dev/null
+++ b/examples/with-langchain/components/TodosPanel.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { useLangChainState } from "@assistant-ui/react-langchain";
+
+type Todo = {
+  id?: string;
+  content: string;
+  status?: "pending" | "in_progress" | "completed";
+};
+
+const statusIcon = (status: Todo["status"]) => {
+  switch (status) {
+    case "completed":
+      return "✓";
+    case "in_progress":
+      return "◐";
+    default:
+      return "○";
+  }
+};
+
+export function TodosPanel() {
+  const todos = useLangChainState<Todo[]>("todos", []);
+
+  if (todos.length === 0) {
+    return (
+      <div className="text-muted-foreground text-sm">
+        Todos from the agent will appear here. This panel reads{" "}
+        <code className="rounded bg-muted px-1">state.todos</code> via{" "}
+        <code className="rounded bg-muted px-1">useLangChainState</code>.
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-2">
+      <h2 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
+        Agent todos
+      </h2>
+      <ul className="space-y-1">
+        {todos.map((todo, i) => (
+          <li
+            key={todo.id ?? i}
+            className="flex items-start gap-2 text-sm leading-relaxed"
+          >
+            <span aria-hidden>{statusIcon(todo.status)}</span>
+            <span
+              className={
+                todo.status === "completed"
+                  ? "text-muted-foreground line-through"
+                  : undefined
+              }
+            >
+              {todo.content}
+            </span>
+          </li>
+        ))}
+      </ul>
+    </div>
+  );
+}
diff --git a/examples/with-langchain/next.config.ts b/examples/with-langchain/next.config.ts
new file mode 100644
index 0000000000..cd9bdd3ccb
--- /dev/null
+++ b/examples/with-langchain/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  transpilePackages: ["@assistant-ui/react", "@assistant-ui/react-langchain"],
+};
+
+export default nextConfig;
diff --git a/examples/with-langchain/package.json b/examples/with-langchain/package.json
new file mode 100644
index 0000000000..cc43475fd1
--- /dev/null
+++ b/examples/with-langchain/package.json
@@ -0,0 +1,37 @@
+{
+  "name": "with-langchain",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "next dev",
+    "build": "next build",
+    "start": "next start"
+  },
+  "dependencies": {
+    "@assistant-ui/react": "workspace:*",
+    "@assistant-ui/react-langchain": "workspace:*",
+    "@assistant-ui/react-markdown": "workspace:*",
+    "@assistant-ui/ui": "workspace:*",
+    "@langchain/langgraph-sdk": "^1.8.8",
+    "@langchain/react": "^0.3.3",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "lucide-react": "^1.8.0",
+    "next": "^16.2.3",
+    "react": "^19.2.5",
+    "react-dom": "^19.2.5",
+    "tailwind-merge": "^3.5.0"
+  },
+  "devDependencies": {
+    "@assistant-ui/x-buildutils": "workspace:*",
+    "@tailwindcss/postcss": "^4.2.2",
+    "@types/node": "^25.6.0",
+    "@types/react": "^19.2.14",
+    "@types/react-dom": "^19.2.3",
+    "postcss": "^8.5.9",
+    "tailwindcss": "^4.2.2",
+    "tw-animate-css": "^1.4.0",
+    "typescript": "^6.0.2"
+  }
+}
diff --git a/examples/with-langchain/postcss.config.mjs b/examples/with-langchain/postcss.config.mjs
new file mode 100644
index 0000000000..28c57667eb
--- /dev/null
+++ b/examples/with-langchain/postcss.config.mjs
@@ -0,0 +1,6 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: ["@tailwindcss/postcss"],
+};
+
+export default config;
diff --git a/examples/with-langchain/tsconfig.json b/examples/with-langchain/tsconfig.json
new file mode 100644
index 0000000000..a23497685b
--- /dev/null
+++ b/examples/with-langchain/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "extends": "@assistant-ui/x-buildutils/ts/next",
+  "compilerOptions": {
+    "paths": {
+      "@/*": ["./*"],
+      "@/components/assistant-ui/*": [
+        "../../packages/ui/src/components/assistant-ui/*"
+      ],
+      "@/components/ui/*": ["../../packages/ui/src/components/ui/*"],
+      "@/lib/utils": ["../../packages/ui/src/lib/utils"],
+      "@assistant-ui/ui/*": ["../../packages/ui/src/*"]
+    }
+  },
+  "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}
diff --git a/packages/react-langchain/README.md b/packages/react-langchain/README.md
new file mode 100644
index 0000000000..605324b1d1
--- /dev/null
+++ b/packages/react-langchain/README.md
@@ -0,0 +1,111 @@
+## `@assistant-ui/react-langchain`
+
+Adapter that wraps [`useStream`](https://docs.langchain.com/oss/javascript/langgraph-sdk/react-stream) from `@langchain/react` and exposes it as an assistant-ui runtime, with hooks for reading interrupts, submitting raw state updates, and reading arbitrary LangGraph custom state keys.
+
+> assistant-ui also ships [`@assistant-ui/react-langgraph`](https://www.npmjs.com/package/@assistant-ui/react-langgraph), which integrates with `@langchain/langgraph-sdk` directly and has a broader feature set (subgraph events, UI messages, message metadata, cancellation). The two packages are independent adapters targeting different upstream libraries — pick whichever matches your upstream choice. See the [comparison](https://www.assistant-ui.com/docs/runtimes/langchain/comparison).
+
+## Features
+
+- Bridges LangChain's `useStream` to an assistant-ui `AssistantRuntime`
+- Tool invocations flow through assistant-ui's `useToolInvocations`
+- `useLangChainInterruptState` — read the current LangGraph interrupt
+- `useLangChainSubmit` — submit raw state updates (e.g. resume commands)
+- `useLangChainState<T>(key)` — read custom state keys like `todos` / `files` reactively
+- Optional assistant-cloud thread persistence
+
+## Installation
+
+```bash
+npm install @assistant-ui/react @assistant-ui/react-langchain @langchain/react
+```
+
+## Usage
+
+### Basic setup
+
+```tsx
+import { useStreamRuntime } from "@assistant-ui/react-langchain";
+import { AssistantRuntimeProvider, Thread } from "@assistant-ui/react";
+
+function App() {
+  const runtime = useStreamRuntime({
+    assistantId: "agent",
+    apiUrl: "http://localhost:2024",
+  });
+
+  return (
+    <AssistantRuntimeProvider runtime={runtime}>
+      <Thread />
+    </AssistantRuntimeProvider>
+  );
+}
+```
+
+`useStreamRuntime` accepts every option `useStream` from `@langchain/react` does, plus:
+
+- `cloud` — an `AssistantCloud` instance for persisting threads
+- `adapters` — `{ attachments, speech, feedback }`
+- `messagesKey` — the state key that holds messages (default `"messages"`)
+
+### Reading custom state keys
+
+LangGraph agents often expose structured state beyond messages (plans, todos, scratch files, generative-UI artifacts). Read them directly with `useLangChainState` — mirrors `useStream().values[key]` upstream and updates when the stream emits new state.
+
+```tsx
+import { useLangChainState } from "@assistant-ui/react-langchain";
+
+type Todo = { id: string; title: string; done: boolean };
+
+function TodoList() {
+  const todos = useLangChainState<Todo[]>("todos", []);
+
+  return (
+    <ul>
+      {todos.map((t) => (
+        <li key={t.id}>{t.title}</li>
+      ))}
+    </ul>
+  );
+}
+```
+
+Signatures:
+
+```ts
+useLangChainState<T>(key: string): T | undefined;
+useLangChainState<T>(key: string, defaultValue: T): T;
+```
+
+### Interrupts
+
+```tsx
+import {
+  useLangChainInterruptState,
+  useLangChainSubmit,
+} from "@assistant-ui/react-langchain";
+import { Command } from "@langchain/langgraph-sdk";
+
+function InterruptPrompt() {
+  const interrupt = useLangChainInterruptState();
+  const submit = useLangChainSubmit();
+
+  if (!interrupt) return null;
+
+  return (
+    <div>
+      <pre>{JSON.stringify(interrupt.value, null, 2)}</pre>
+      <button onClick={() => submit(null, { command: new Command({ resume: "yes" }) })}>
+        Approve
+      </button>
+    </div>
+  );
+}
+```
+
+### Message conversion
+
+The adapter ships `convertLangChainBaseMessage` for cases where you want to reuse the internal converter directly (e.g. when building a custom `ExternalStoreAdapter`).
+
+```ts
+import { convertLangChainBaseMessage } from "@assistant-ui/react-langchain";
+```
diff --git a/packages/react-langchain/package.json b/packages/react-langchain/package.json
index 6d8e4ee6a8..d861bb2302 100644
--- a/packages/react-langchain/package.json
+++ b/packages/react-langchain/package.json
@@ -29,7 +29,9 @@
   ],
   "sideEffects": false,
   "scripts": {
-    "build": "aui-build"
+    "build": "aui-build",
+    "test": "vitest run",
+    "test:watch": "vitest"
   },
   "dependencies": {
     "@assistant-ui/core": "^0.1.14",
@@ -52,8 +54,11 @@
     "@langchain/core": "^1.1.39",
     "@langchain/langgraph-sdk": "^1.8.8",
     "@langchain/react": "^0.3.3",
+    "@testing-library/react": "^16.3.2",
     "@types/react": "^19.2.14",
-    "react": "^19.2.5"
+    "jsdom": "^29.0.2",
+    "react": "^19.2.5",
+    "vitest": "^4.1.4"
   },
   "publishConfig": {
     "access": "public",
diff --git a/packages/react-langchain/src/index.ts b/packages/react-langchain/src/index.ts
index 5ae6d4b497..87d44e4369 100644
--- a/packages/react-langchain/src/index.ts
+++ b/packages/react-langchain/src/index.ts
@@ -1,6 +1,7 @@
 export {
   useStreamRuntime,
   useLangChainInterruptState,
+  useLangChainState,
   useLangChainSubmit,
 } from "./useStreamRuntime";
 export type { UseStreamRuntimeOptions } from "./useStreamRuntime";
diff --git a/packages/react-langchain/src/useStreamRuntime.ts b/packages/react-langchain/src/useStreamRuntime.ts
index f94b45b9a4..6a2e0b549e 100644
--- a/packages/react-langchain/src/useStreamRuntime.ts
+++ b/packages/react-langchain/src/useStreamRuntime.ts
@@ -31,6 +31,7 @@ type LangChainRuntimeExtras = {
     values: Record<string, unknown> | null | undefined,
     options?: Record<string, unknown>,
   ) => Promise<void>;
+  values: Record<string, unknown>;
 };
 
 const asLangChainRuntimeExtras = (extras: unknown): LangChainRuntimeExtras => {
@@ -183,8 +184,9 @@ const useStreamThreadRuntime = ({
       interrupt: stream.interrupt,
       interrupts: stream.interrupts,
       submit: stream.submit,
+      values: stream.values,
     }),
-    [stream.interrupt, stream.interrupts, stream.submit],
+    [stream.interrupt, stream.interrupts, stream.submit, stream.values],
   );
 
   // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
@@ -298,3 +300,28 @@ export const useLangChainSubmit = () => {
     return submit(values, options);
   };
 };
+
+/**
+ * Read a custom LangGraph state key from the current thread. Mirrors
+ * `useStream().values[key]` from `@langchain/react` and updates when the
+ * stream emits new state.
+ *
+ * @example
+ * ```tsx
+ * const todos = useLangChainState<Todo[]>("todos");
+ * const files = useLangChainState<Record<string, string>>("files", {});
+ * ```
+ */
+export function useLangChainState<T>(key: string): T | undefined;
+export function useLangChainState<T>(key: string, defaultValue: T): T;
+export function useLangChainState<T>(
+  key: string,
+  defaultValue?: T,
+): T | undefined {
+  return useAuiState((s) => {
+    const extras = s.thread.extras;
+    if (!extras) return defaultValue;
+    const value = asLangChainRuntimeExtras(extras).values[key] as T | undefined;
+    return value !== undefined ? value : defaultValue;
+  });
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3b0a43f06f..633ba46b03 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1818,6 +1818,76 @@ importers:
         specifier: ^6.0.2
         version: 6.0.2
 
+  examples/with-langchain:
+    dependencies:
+      '@assistant-ui/react':
+        specifier: workspace:*
+        version: link:../../packages/react
+      '@assistant-ui/react-langchain':
+        specifier: workspace:*
+        version: link:../../packages/react-langchain
+      '@assistant-ui/react-markdown':
+        specifier: workspace:*
+        version: link:../../packages/react-markdown
+      '@assistant-ui/ui':
+        specifier: workspace:*
+        version: link:../../packages/ui
+      '@langchain/langgraph-sdk':
+        specifier: ^1.8.8
+        version: 1.8.9(@langchain/core@1.1.40(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+      '@langchain/react':
+        specifier: ^0.3.3
+        version: 0.3.3(@langchain/core@1.1.40(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+      class-variance-authority:
+        specifier: ^0.7.1
+        version: 0.7.1
+      clsx:
+        specifier: ^2.1.1
+        version: 2.1.1
+      lucide-react:
+        specifier: ^1.8.0
+        version: 1.8.0(react@19.2.5)
+      next:
+        specifier: ^16.2.3
+        version: 16.2.3(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+      react:
+        specifier: ^19.2.5
+        version: 19.2.5
+      react-dom:
+        specifier: ^19.2.5
+        version: 19.2.5(react@19.2.5)
+      tailwind-merge:
+        specifier: ^3.5.0
+        version: 3.5.0
+    devDependencies:
+      '@assistant-ui/x-buildutils':
+        specifier: workspace:*
+        version: link:../../packages/x-buildutils
+      '@tailwindcss/postcss':
+        specifier: ^4.2.2
+        version: 4.2.2
+      '@types/node':
+        specifier: ^25.6.0
+        version: 25.6.0
+      '@types/react':
+        specifier: ^19.2.14
+        version: 19.2.14
+      '@types/react-dom':
+        specifier: ^19.2.3
+        version: 19.2.3(@types/react@19.2.14)
+      postcss:
+        specifier: ^8.5.9
+        version: 8.5.9
+      tailwindcss:
+        specifier: ^4.2.2
+        version: 4.2.2
+      tw-animate-css:
+        specifier: ^1.4.0
+        version: 1.4.0
+      typescript:
+        specifier: ^6.0.2
+        version: 6.0.2
+
   examples/with-langgraph:
     dependencies:
       '@assistant-ui/react':
@@ -3186,12 +3256,21 @@ importers:
       '@langchain/react':
         specifier: ^0.3.3
         version: 0.3.3(@langchain/core@1.1.39(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+      '@testing-library/react':
+        specifier: ^16.3.2
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
       '@types/react':
         specifier: ^19.2.14
         version: 19.2.14
+      jsdom:
+        specifier: ^29.0.2
+        version: 29.0.2
       react:
         specifier: ^19.2.5
         version: 19.2.5
+      vitest:
+        specifier: ^4.1.4
+        version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
 
   packages/react-langgraph:
     dependencies:
@@ -18401,7 +18480,17 @@ snapshots:
   '@langchain/react@0.3.3(@langchain/core@1.1.39(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
     dependencies:
       '@langchain/core': 1.1.39(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)
-      '@langchain/langgraph-sdk': 1.8.8(@langchain/core@1.1.39(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+      '@langchain/langgraph-sdk': 1.8.9(@langchain/core@1.1.39(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+      react: 19.2.5
+    transitivePeerDependencies:
+      - react-dom
+      - svelte
+      - vue
+
+  '@langchain/react@0.3.3(@langchain/core@1.1.40(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+    dependencies:
+      '@langchain/core': 1.1.40(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)
+      '@langchain/langgraph-sdk': 1.8.9(@langchain/core@1.1.40(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
       react: 19.2.5
     transitivePeerDependencies:
       - react-dom


Test patch:
diff --git a/packages/react-langchain/src/useLangChainState.test.tsx b/packages/react-langchain/src/useLangChainState.test.tsx
new file mode 100644
index 0000000000..c7978561f5
--- /dev/null
+++ b/packages/react-langchain/src/useLangChainState.test.tsx
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+
+type Selector = (state: unknown) => unknown;
+const { mockUseAuiState } = vi.hoisted(() => ({
+  mockUseAuiState: vi.fn(),
+}));
+
+vi.mock(import("@assistant-ui/store"), async (importOriginal) => {
+  const actual = await importOriginal();
+  return {
+    ...actual,
+    useAuiState: ((selector: Selector) =>
+      mockUseAuiState(selector)) as typeof actual.useAuiState,
+    useAui: (() => ({})) as unknown as typeof actual.useAui,
+  };
+});
+
+import { useLangChainState } from "./useStreamRuntime";
+
+// The real guard symbol in useStreamRuntime is module-private. To stand in for
+// runtime-produced extras, we use a Proxy whose `has` trap claims any symbol
+// with the description "langchain-runtime-extras" is present.
+const makeExtras = (values: Record<string, unknown>) =>
+  new Proxy(
+    { values },
+    {
+      has: (target, key) =>
+        (typeof key === "symbol" &&
+          key.description === "langchain-runtime-extras") ||
+        Reflect.has(target, key),
+    },
+  );
+
+const runSelectorAgainst = (extras: unknown) => {
+  mockUseAuiState.mockImplementationOnce((selector: Selector) =>
+    selector({ thread: { extras } }),
+  );
+};
+
+describe("useLangChainState", () => {
+  it("returns undefined when extras are absent", () => {
+    runSelectorAgainst(undefined);
+    const { result } = renderHook(() => useLangChainState<number>("count"));
+    expect(result.current).toBeUndefined();
+  });
+
+  it("returns defaultValue when extras are absent", () => {
+    runSelectorAgainst(undefined);
+    const { result } = renderHook(() => useLangChainState<number>("count", 42));
+    expect(result.current).toBe(42);
+  });
+
+  it("reads the value for the given key from extras.values", () => {
+    runSelectorAgainst(makeExtras({ todos: [{ id: "a" }], count: 7 }));
+    const { result } = renderHook(() =>
+      useLangChainState<Array<{ id: string }>>("todos"),
+    );
+    expect(result.current).toEqual([{ id: "a" }]);
+  });
+
+  it("falls back to defaultValue when the key is missing from values", () => {
+    runSelectorAgainst(makeExtras({ unrelated: "x" }));
+    const { result } = renderHook(() => useLangChainState<number>("count", 99));
+    expect(result.current).toBe(99);
+  });
+
+  it("returns undefined when the key is missing and no default is given", () => {
+    runSelectorAgainst(makeExtras({ unrelated: "x" }));
+    const { result } = renderHook(() => useLangChainState<number>("count"));
+    expect(result.current).toBeUndefined();
+  });
+
+  it("returns the stored value (not default) when both are present", () => {
+    runSelectorAgainst(makeExtras({ count: 0 }));
+    const { result } = renderHook(() => useLangChainState<number>("count", 99));
+    // 0 is a valid value; must be preserved over the default.
+    expect(result.current).toBe(0);
+  });
+
+  it("preserves explicit null over defaultValue", () => {
+    runSelectorAgainst(makeExtras({ flag: null }));
+    const { result } = renderHook(() =>
+      useLangChainState<string | null>("flag", "default"),
+    );
+    // LangGraph treats null as an explicit "cleared" value distinct
+    // from a missing key; the hook must not conflate them.
+    expect(result.current).toBeNull();
+  });
+});