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.

  1. Type a change and hit Save. You'll see a single request appear: PUT /api/tags/<uuid>.
  2. Click that request → Headers tab. Confirm:
    • Content-Type: application/json and an Authorization: Bearer ... header — both added invisibly by makeRequest (Step 10).
  3. Click Payload (or Request). The body is exactly your UpdateTag shape from Step 9: {"tag_name": "...", "content": "..."} — no extra fields, because TagEditDialog builds precisely that type.
  4. Click Response. You'll see the backend's envelope from Step 8's ApiResponse::success(tag) — {"success": true, "data": { "id": "...", "tag_name": "...", ... }} — matching Tag field-for-field.
  5. Switch to the React Query DevTools panel (if enabled in your local build) and watch the ['tags', teamId] query — if this dialog were wired through useTags instead of a direct call, you'd see its cache entry update the instant onSuccess fires, 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.