从 "use client" 到 Route Handler:用 Next.js Todos 串起水合、状态与前后端请求
掌握 App Router 的页面、路由和布局之后,下一步通常不是继续增加静态页面,而是让页面真正"动起来":输入一条待办事项,点击按钮,把数据发送给后端,再将结果显示在列表中。
这个看似简单的 Todos 功能,刚好把 Next.js 全栈开发中几个容易混淆的概念连在了一起:
- 什么情况下必须写
"use client"; - Client Component 是否只在浏览器渲染;
- 水合到底给静态 HTML 增加了什么;
useEffect为什么适合在组件挂载后请求接口;route.ts如何同时处理 GET 和 POST;- 为什么
fetch()成功了,页面却可能没有显示新数据; - TypeScript 类型如何在页面和接口之间复用。
本文会围绕一条完整的数据链路展开:
text
浏览器中的 Todos 页面
↓ GET /api/todos
Next.js Route Handler
↓ JSON
页面更新 todos 状态并渲染列表
用户输入新任务并点击添加
↓ POST /api/todos
Route Handler 创建新 Todo
↓ 返回新 Todo
页面把新数据加入列表
一、先看清页面和接口的职责边界
Todos 功能只需要三个核心模块:
text
app/
├── todos/
│ ├── page.tsx # /todos 页面
│ └── types.ts # Todo 类型
└── api/
└── todos/
└── route.ts # /api/todos 接口
虽然页面和接口都处于 app 目录中,但它们承担的任务并不相同:
| 模块 | 对应地址 | 作用 |
|---|---|---|
todos/page.tsx |
/todos |
渲染可以交互的待办页面 |
api/todos/route.ts |
/api/todos |
接收 HTTP 请求并返回 JSON |
todos/types.ts |
无独立地址 | 定义前后端共同使用的数据结构 |
page.tsx 返回 React UI,route.ts 返回数据。浏览器访问 /todos 看到的是页面,页面内部再请求 /api/todos 获得待办数组。
二、为什么 Todos 页面必须使用 "use client"
App Router 中的页面和布局默认是 Server Component,但 Todos 页面使用了下面这些能力:
tsx
useState(...)
useEffect(...)
onChange={...}
onClick={...}
状态、Effect 和事件处理器都需要在浏览器中运行,因此要在模块顶部声明客户端边界:
tsx
"use client";
import { useEffect, useState } from "react";
这条指令必须位于 import 之前。它表达的不是"这段 HTML 只能由浏览器生成",而是:这个模块及其客户端依赖需要把 JavaScript 发送到浏览器,以便使用状态、生命周期和事件。
如果没有 "use client",下面这些交互就无法在默认 Server Component 中使用:
tsx
const [text, setText] = useState("");
<input onChange={(event) => setText(event.target.value)} />
<button onClick={handleAdd}>添加</button>
不能把"没有 use client"简单理解成"都是后端"
这个说法过于绝对。更准确的区分是:
page.tsx、layout.tsx默认是 Server Component;- 写了
"use client"的模块形成客户端边界; route.ts是处理 HTTP 请求的服务端 Route Handler;- 只包含类型的
types.ts可以被前后端共同引用。
尤其是 types.ts,它没有 "use client",但也不是一个接口或页面。它只是共享 TypeScript 类型,最终通过 import type 引入时不会形成运行时逻辑。
三、Client Component 首次打开时也会经历服务端预渲染
Client Component 不等于传统 SPA 中"服务器只返回一个空的 #root"。首次访问 /todos 时,Next.js 仍然会在服务端为页面生成 HTML,让用户先看到不可交互的页面预览;随后浏览器加载 JavaScript 并进行水合,页面才真正拥有交互能力。
整个过程可以分为三步:
text
第一步:服务端预渲染 HTML
第二步:浏览器直接显示 HTML
第三步:React 水合并绑定事件,页面变得可交互
什么是水合
水合(Hydration)可以理解为 React 把事件处理能力附加到已有 DOM 上的过程。
例如下面这个输入框:
tsx
<input
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="请输入新的待办任务"
/>
在 JavaScript 完成水合之前:
- 输入框和
placeholder可以已经存在于 HTML 中; onChange对应的交互逻辑还不能工作。
水合完成后,React 才能在用户输入时执行 setText,并根据最新状态更新页面。
因此,不宜把水合描述为"服务器渲染一次,客户端把缺少的地方随便打个补丁"。更准确的理解是:服务器提供初始 HTML,客户端使用对应的组件树和 JavaScript 接管这份 HTML,再让状态与事件开始工作。
四、用一个共享类型约束 Todo 数据
每条待办事项都包含三个字段:
tsx
export type Todo = {
id: number;
content: string;
completed: boolean;
};
它们分别表示:
id:每条数据的唯一标识;content:待办事项的文字内容;completed:任务是否完成。
页面和接口都使用同一个 Todo 类型:
tsx
import type { Todo } from "./types";
tsx
import type { Todo } from "../../todos/types";
import type 明确表示这里只需要类型信息。这样一来,接口返回的数据结构和页面期待的数据结构由同一份定义约束,避免一边叫 content、另一边却误写成其他字段。
类型只在编译阶段帮助检查代码,并不会替代接口数据本身。浏览器真正拿到的仍然是 JSON。
strict: false 不会让显式类型失去作用
TypeScript 配置中使用了:
json
{
"compilerOptions": {
"strict": false
}
}
这会放宽项目整体的类型检查,例如没有明确标注的参数不会因为隐式 any 直接报错。不过,Todo[]、string 和 Todo 这些显式类型仍然会约束对应状态和对象。
还要区分"TypeScript 类型"和"运行时数据":req.json() 解析的是客户端实际传来的 JSON,Todo 类型不会在运行时自动验证这份数据。当前示例由受控输入框和 trim() 保证正常页面不会提交空内容,类型主要负责约束代码中的数据形状。
五、用 Route Handler 编写 GET 接口
App Router 使用 route.ts 创建自定义请求处理器。目录位置决定接口 URL:
text
app/api/todos/route.ts → /api/todos
先准备两条保存在内存中的假数据:
tsx
import type { Todo } from "../../todos/types";
const todos: Todo[] = [
{ id: 1, content: "学习 App Router", completed: true },
{ id: 2, content: "Next.js 个人官网开发", completed: false },
];
这里更适合使用 const。虽然 POST 请求还会执行 todos.push(...),但 push 修改的是数组内容,并没有让变量重新指向另一个数组,所以不需要 let。
接下来导出一个与 HTTP 方法同名的 GET 函数:
tsx
export async function GET() {
return Response.json(todos);
}
当浏览器发送 GET /api/todos 时,Next.js 会调用这个函数。Response.json(todos) 会创建 JSON 响应,把数组放入响应体。
这里使用的是 Route Handler 支持的 Web Response API。它返回的是数据响应,而不是 React 组件,所以直接访问 /api/todos 时看到的应该是类似下面的 JSON:
json
[
{
"id": 1,
"content": "学习 App Router",
"completed": true
},
{
"id": 2,
"content": "Next.js 个人官网开发",
"completed": false
}
]
这些数据只保存在当前服务进程的内存中。它适合演示接口调用,但开发服务器重启后,后来添加的数据就会消失。
六、在组件挂载后请求 Todos
页面使用一个数组状态保存接口数据:
tsx
const [todos, setTodos] = useState<Todo[]>([]);
初始值是空数组,所以页面第一次渲染时还没有任何列表项。组件完成挂载后,再在 useEffect 中请求接口:
tsx
useEffect(() => {
const loadTodos = async () => {
const res = await fetch("/api/todos");
const data: Todo[] = await res.json();
setTodos(data);
};
void loadTodos();
}, []);
空依赖数组 [] 表示这个 Effect 用于组件挂载后的初始化同步,而不是在每次渲染后都重新请求。
把异步加载函数定义在 Effect 内部,还能清楚表达它只服务于这次初始化,并避免把一个会间接更新状态的外部函数直接放进 Effect。
fetch() 返回的不是 JSON 数据本身
下面这行拿到的是 Response 对象:
tsx
const res = await fetch("/api/todos");
如果只执行:
tsx
console.log(res);
控制台看到的会是状态码、响应头和响应体状态等信息,而不是已经解析好的 Todo 数组。还需要调用:
tsx
const data: Todo[] = await res.json();
然后把数据交给 React 状态:
tsx
setTodos(data);
状态更新后,React 会重新渲染组件,列表内容才真正出现在页面中。
为什么这部分内容仍然属于客户端取数
页面虽然是 Client Component,并且首次打开时会得到预渲染 HTML,但 useEffect 只会在浏览器完成水合后运行。初始状态又是空数组,因此服务端生成的初始列表也是空的。
数据出现的顺序是:
text
先看到标题、输入框和按钮
↓
浏览器完成水合
↓
useEffect 请求 /api/todos
↓
setTodos 更新状态
↓
列表出现在页面中
这正是客户端异步取数的特点:页面骨架可以预渲染,但 Todo 正文要等浏览器请求完成后才出现。
七、用 map 把状态渲染成列表
当 todos 更新后,可以通过 map 生成列表项:
tsx
<ul style={{ paddingLeft: "0", listStyle: "none" }}>
{todos.map((item) => (
<li
key={item.id}
style={{ margin: "8px 0", display: "flex", gap: "10px" }}
>
<span
style={{
textDecoration: item.completed ? "line-through" : "none",
cursor: "pointer",
}}
>
{item.content}
</span>
<button>删除</button>
</li>
))}
</ul>
这里包含三处重要细节。
1. key 来自 Todo 的 id
tsx
key={item.id}
列表中的每条数据都有稳定标识,React 可以据此区分各个列表项。
2. 根据 completed 计算样式
tsx
textDecoration: item.completed ? "line-through" : "none"
已完成的任务显示删除线,未完成任务保持正常文字。这里没有维护另一份样式状态,而是直接根据 Todo 数据计算 UI。
3. "看起来可以点击"不等于已经实现交互
cursor: "pointer" 只会改变鼠标样式,并不会切换完成状态;"删除"按钮当前也没有 onClick。它们只是为后续功能留下了界面位置,现阶段真正实现的是查询和添加,不应误认为删除与切换已经完成。
八、受控输入框如何保存用户输入
新增任务前,需要用字符串状态保存输入内容:
tsx
const [text, setText] = useState<string>("");
输入框同时绑定 value 和 onChange:
tsx
<input
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="请输入新的待办任务"
/>
数据流始终是单向的:
text
用户输入
↓
触发 onChange
↓
setText 更新状态
↓
新的 text 成为 input 的 value
这就是受控输入框。React 状态是输入值的来源,提交时也可以直接读取 text。
九、POST 请求如何把新 Todo 交给接口
点击"添加"按钮会触发 handleAdd:
tsx
<button onClick={handleAdd} style={{ marginLeft: "8px" }}>
添加
</button>
发送请求前,先排除空字符串和只包含空格的内容:
tsx
const content = text.trim();
if (!content) return;
然后向同一个地址发送 POST 请求:
tsx
const res = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
这几个配置各有作用:
method: "POST"表示创建数据;Content-Type: application/json说明请求体采用 JSON 格式;JSON.stringify把 JavaScript 对象转换成请求体字符串。
服务端通过同一个 route.ts 中的 POST 函数接收请求:
tsx
export async function POST(req: Request) {
const body = await req.json();
const newTodo: Todo = {
id: Date.now(),
content: body.content,
completed: false,
};
todos.push(newTodo);
return Response.json(newTodo);
}
它依次完成四件事:
- 使用
req.json()解析请求体; - 根据
content创建一条新的 Todo; - 使用当前时间戳作为数字
id; - 把新数据加入内存数组并返回 JSON。
这里返回的是新建的单条 Todo,而不是整个数组。这正好可以让页面直接使用响应结果更新本地状态。
十、请求成功不代表页面会自动变化
如果 handleAdd 只发送 POST:
tsx
await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: text }),
});
服务端数组确实增加了数据,但 React 的 todos 状态没有变化,所以页面列表不会立即新增一项。浏览器也不会因为接口返回了 JSON,就自动把 JSON 放进组件状态。
一个贴近现有数据流的完成方式,是读取 POST 返回的新 Todo,然后追加到数组并清空输入框:
tsx
const handleAdd = async () => {
const content = text.trim();
if (!content) return;
const res = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) return;
const newTodo: Todo = await res.json();
setTodos((currentTodos) => [...currentTodos, newTodo]);
setText("");
};
这里使用函数形式更新状态:
tsx
setTodos((currentTodos) => [...currentTodos, newTodo]);
它基于当前数组创建一个新数组,把新 Todo 放在最后。setText("") 随后清空受控输入框。
完整的添加链路因此变成:
text
text 保存输入内容
↓
点击按钮触发 handleAdd
↓
POST /api/todos
↓
服务端创建并返回 newTodo
↓
页面解析 JSON
↓
setTodos 追加新数据
↓
列表重新渲染,输入框被清空
十一、为什么开发者工具里会看到很多请求
打开 /todos 后,Network 面板不会只有一条接口请求。Next.js 开发环境还需要加载和维护页面本身,因此可能同时看到:
/todos:页面请求;/_next/static/...:页面需要的 JavaScript 和样式资源;- 带有
_rsc参数的请求:React Server Component 导航数据; - 开发环境的热更新连接;
/api/todos:真正返回假数据的自定义接口。
排查 Todos 数据时,可以在 Network 中选择 Fetch/XHR,再搜索 todos。需要查看的是路径为 /api/todos 的请求,而不是 /todos 或带 _rsc 参数的页面请求。
选中 /api/todos 后:
Headers可以确认请求方法是 GET 还是 POST;Payload可以查看 POST 发送的{ content: ... };Preview或Response可以查看接口返回的 Todo JSON。
为什么开发环境中 GET 可能出现两次
App Router 默认启用 React Strict Mode。在开发模式中,React 可能额外执行一次挂载流程来帮助发现副作用问题,于是初始化 Effect 也可能再次执行,Network 中就会看到两条相同的 GET 请求。
这不等于代码进入了无限循环。当前 Effect 的依赖数组是 [],没有因为 setTodos 而在每次渲染后持续请求。额外执行主要是开发模式的检查行为,不能据此认为生产环境必然会发送两次初始化请求。
十二、一份完整且贴近原始思路的实现
把前面的数据流组合起来,接口可以写成:
tsx
import type { Todo } from "../../todos/types";
const todos: Todo[] = [
{ id: 1, content: "学习 App Router", completed: true },
{ id: 2, content: "Next.js 个人官网开发", completed: false },
];
export async function GET() {
return Response.json(todos);
}
export async function POST(req: Request) {
const body = await req.json();
const newTodo: Todo = {
id: Date.now(),
content: body.content,
completed: false,
};
todos.push(newTodo);
return Response.json(newTodo);
}
客户端页面可以写成:
tsx
"use client";
import { useEffect, useState } from "react";
import type { Todo } from "./types";
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState<string>("");
useEffect(() => {
const loadTodos = async () => {
const res = await fetch("/api/todos");
if (!res.ok) return;
const data: Todo[] = await res.json();
setTodos(data);
};
void loadTodos();
}, []);
const handleAdd = async () => {
const content = text.trim();
if (!content) return;
const res = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) return;
const newTodo: Todo = await res.json();
setTodos((currentTodos) => [...currentTodos, newTodo]);
setText("");
};
return (
<div style={{ marginTop: "12px" }}>
<h1>待办事项</h1>
<input
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="请输入新的待办任务"
/>
<button onClick={handleAdd} style={{ marginLeft: "8px" }}>
添加
</button>
<ul style={{ paddingLeft: "0", listStyle: "none" }}>
{todos.map((item) => (
<li
key={item.id}
style={{ margin: "8px 0", display: "flex", gap: "10px" }}
>
<span
style={{
textDecoration: item.completed ? "line-through" : "none",
cursor: "pointer",
}}
>
{item.content}
</span>
<button>删除</button>
</li>
))}
</ul>
</div>
);
}
这份实现仍然保持了最初的结构:useEffect 获取数据,handleAdd 提交数据,Route Handler 负责 GET 和 POST。只补齐了三个关键环节:
- 检查响应是否成功;
- 读取 POST 返回的 JSON;
- 添加成功后同步更新页面状态并清空输入框。
删除按钮和点击文字切换完成状态仍然没有对应接口与事件,因此保留为未实现的界面,不继续扩展出当前功能之外的代码。
十三、几个容易混淆的边界
1. Response 和响应数据不是一回事
tsx
const res = await fetch(...);
这里得到的是响应对象。只有 await res.json() 之后,才拿到 JavaScript 数据。
2. 接口数据改变和 React 状态改变不是一回事
POST 可以成功修改服务端内存数组,但页面只有在执行 setTodos 后才会重新渲染。
3. Client Component 和纯 CSR 不是一回事
Client Component 首次访问时仍可参与服务端预渲染,随后在浏览器水合;但放在 useEffect 中获取的 Todo 数据要等客户端请求完成后才出现。
4. 水合和重新获取数据不是一回事
水合负责让已有 HTML 获得 React 交互能力;fetch("/api/todos") 则是页面主动发起的另一个数据请求。
5. const 和"数组内容不能修改"不是一回事
const todos = [] 禁止变量重新指向另一个数组,但仍允许 todos.push(newTodo) 修改数组内容。
总结
一个简单的 Todos 页面已经包含了一条完整的 Next.js 全栈链路:
"use client"建立客户端边界,让组件可以使用状态、Effect 和事件;- 页面首次打开时先得到预渲染 HTML,随后通过水合激活交互;
useEffect在浏览器端请求/api/todos;route.ts根据 GET 或 POST 执行对应处理函数;Response.json返回 JSON,页面再通过res.json()解析;setTodos把接口结果变成 React 状态,驱动列表重新渲染;- 受控输入框通过
value和onChange保存用户输入; - POST 成功后还要主动更新状态,页面不会因为接口成功而自动变化;
Todo类型让页面与接口共享同一份数据结构;- 开发环境中的框架资源请求和 Strict Mode 额外执行,不应与业务接口混为一谈。
理解这条链路之后,就能真正看清 Next.js 中"全栈"的含义:前端组件负责状态和交互,Route Handler 负责接收请求与返回数据,而 TypeScript 类型把两端的数据约定连接起来。页面是否变化,最终取决于接口数据有没有被解析,以及解析后的结果有没有进入 React 状态。