文档
Remix Loader/Action 联系人管理
目标
展示 Remix 核心模式:loader 加载数据、action 处理表单、无需 useState 管理状态。
完整代码
app/routes/_index.tsx
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from '@remix-run/node';
import { Form, useLoaderData, useActionData, useNavigation } from '@remix-run/react';
type Contact = { id: number; name: string; phone: string; email: string };
let contacts: Contact[] = [
{ id: 1, name: '张三', phone: '13800138000', email: 'zs@example.com' },
{ id: 2, name: '李四', phone: '13900139000', email: 'ls@example.com' },
];
let nextId = 3;
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const q = url.searchParams.get('q') || '';
const filtered = q ? contacts.filter(c => c.name.includes(q) || c.phone.includes(q)) : contacts;
return json({ contacts: filtered, search: q });
}
export async function action({ request }: ActionFunctionArgs) {
const fd = await request.formData();
const intent = fd.get('_intent');
if (intent === 'delete') {
const id = Number(fd.get('id'));
contacts = contacts.filter(c => c.id !== id);
return json({ ok: true, msg: '已删除' });
}
if (intent === 'add') {
const name = fd.get('name') as string;
const phone = fd.get('phone') as string;
const email = (fd.get('email') as string) || '';
if (!name || !phone) {
return json({ error: '姓名和电话为必填项' }, { status: 400 });
}
contacts.push({ id: nextId++, name, phone, email });
return json({ ok: true, msg: '添加成功' });
}
return null;
}
export default function Index() {
const { contacts: list, search } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const nav = useNavigation();
const busy = nav.state === 'submitting';
return (
<main style={{ maxWidth: 600, margin: '40px auto', fontFamily: 'system-ui' }}>
<h1>📞 联系人管理</h1>
<Form method="get" style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input type="text" name="q" defaultValue={search} placeholder="搜索..." style={inputStyle} />
<button type="submit" style={btnStyle}>搜索</button>
</Form>
{actionData?.ok && <p style={{ color: 'green' }}>{actionData.msg}</p>}
{actionData?.error && <p style={{ color: 'red' }}>{actionData.error}</p>}
<Form method="post" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input type="hidden" name="_intent" value="add" />
<input name="name" placeholder="姓名*" required style={inputStyle} />
<input name="phone" placeholder="电话*" required style={inputStyle} />
<input name="email" placeholder="邮箱" style={inputStyle} />
<button type="submit" disabled={busy} style={btnPrimaryStyle}>
{busy ? '...' : '添加'}
</button>
</Form>
<div>
{list.map(c => (
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', padding: 12, background: 'white', borderRadius: 8, marginBottom: 8, boxShadow: '0 1px 3px rgba(0,0,0,.1)' }}>
<div>
<strong>{c.name}</strong>
<div style={{ color: '#666', fontSize: '0.9em' }}>{c.phone} · {c.email}</div>
</div>
<Form method="post">
<input type="hidden" name="_intent" value="delete" />
<input type="hidden" name="id" value={c.id} />
<button type="submit" style={{ background: '#ef4444', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>删除</button>
</Form>
</div>
))}
</div>
</main>
);
}
const inputStyle = { flex: 1, padding: 8, border: '1px solid #ddd', borderRadius: 4 };
const btnStyle = { padding: '8px 16px', background: '#6b7280', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' };
const btnPrimaryStyle = { padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' };
运行步骤
npx create-remix@latest remix-contacts
cd remix-contacts
npm run dev
预期输出
- 页面显示预设联系人,搜索框实时过滤
- 填写表单添加联系人,提交后自动刷新(无需手动 setState)
- 删除按钮直接提交 Form,Remix 自动重新验证 loader