Step 12 — The Component + a DevTools Network Trace
Last step. TagEditDialog.tsx is where every layer from Steps 7–11 finally reaches the screen. Then we'll open Chrome DevTools and watch the whole chain fire for a real save.
The component: form state + tagsApi, directly
const TagEditDialogImpl = NiceModal.create<TagEditDialogProps>(({ tag }) => {
const [formData, setFormData] = useState({ tag_name: '', content: '' });
const [saving, setSaving] = useState(false);
const isEditMode = Boolean(tag);
const handleSave = async () => {
if (!formData.tag_name.trim()) {
setError(t('settings.general.tags.dialog.errors.nameRequired'));
return;
}
setSaving(true);
try {
if (isEditMode && tag) {
await tagsApi.update(tag.id, { tag_name: formData.tag_name, content: formData.content || null });
} else {
await tagsApi.create({ tag_name: formData.tag_name, content: formData.content });
}
modal.resolve('saved');
modal.hide();
} catch (err: unknown) {
setError(getErrorMessage(err) || t('settings.general.tags.dialog.errors.saveFailed'));
} finally {
setSaving(false);
}
};
// ...
});
Interesting choice worth noticing: this dialog calls tagsApi.update/.create directly, not through useTags's mutation functions. That's a legitimate variation, not a violation of the pattern — useTags exists for components that need cache-synced lists of tags (a settings page showing every tag), while a modal that only needs to fire one write and report success/failure back to its caller (modal.resolve('saved')) is simpler calling the API client straight. Either is valid; the type contracts from Steps 9–10 (CreateTag, UpdateTag, Tag) are what make both call sites safe.
isEditMode = Boolean(tag) is the same "one component, two modes" shape you'd expect from AgentModeBadge's mode prop in Step 2 — here driven by whether an existing Tag was passed in, rather than an explicit union.
Now: watch it happen in DevTools
With pnpm dev running (Step 3), open any page with a tag editor, open Chrome DevTools → Network tab, filter by Fetch/XHR, and click "Edit" on a tag.
- Type a change and hit Save. You'll see a single request appear:
PUT /api/tags/<uuid>. - Click that request → Headers tab. Confirm:
Content-Type: application/jsonand anAuthorization: Bearer ...header — both added invisibly bymakeRequest(Step 10).
- Click Payload (or Request). The body is exactly your
UpdateTagshape from Step 9:{"tag_name": "...", "content": "..."}— no extra fields, becauseTagEditDialogbuilds precisely that type. - Click Response. You'll see the backend's envelope from Step 8's
ApiResponse::success(tag)—{"success": true, "data": { "id": "...", "tag_name": "...", ... }}— matchingTagfield-for-field. - Switch to the React Query DevTools panel (if enabled in your local build) and watch the
['tags', teamId]query — if this dialog were wired throughuseTagsinstead of a direct call, you'd see its cache entry update the instantonSuccessfires, with no visible refetch.
That single PUT request is the entire tutorial in miniature: a click in TagEditDialog.tsx (Step 12) → tagsApi.update (Step 10) → an Axum handler (Step 8) → TagRepository::update (Step 7) → a row change in Postgres → a Tag-shaped JSON response, decoded back through shared/types.ts (Step 9) into the exact same TypeScript type the dialog started with.
Where to go from here
You've traced one real feature end-to-end. The same five-layer trace — DB repository → Axum route → shared/types.ts → API client → React hook/component — applies to every feature in this codebase. Next time you need to understand or extend one, start at whichever layer is closest to your change and walk outward using this same method.