1. The State Management Question Is Usually Asked Backwards
A fetched ticket, the currently selected ticket, an authenticated user, an editable reply, and a search value are all “state.” They still need different owners. The server owns the saved ticket. Navigation identifies the selected ticket. The user controls the unfinished reply. A search value may be temporary input or a committed view that a link should preserve.
A support agent moving from reading a conversation to generating, editing, and sending a reply crosses several of those boundaries on one screen. Choosing a global-state library first skips the harder questions: who can change each value, how long should it survive, and what makes it authoritative?
My AI Support Assistant provides a concrete example. Its React and TypeScript frontend uses Context for the current session, TanStack Query for ticket and message data, React Router for navigation, and component state for editable forms. Shareable search and filter controls are a proposed addition. The useful architectural lesson is how these responsibilities fit together, including the boundaries I still need to strengthen.
In Frontend System Design: How I Would Architect a Production Dashboard in React, I considered the broader dashboard architecture. Here I want to isolate one question: how should state ownership be divided inside it? I start with ownership, lifetime, and source of truth, then choose the mechanism that fits each responsibility.
2. Start With Ownership, Not Libraries
I start by asking where a value comes from. Is it an authoritative server record, a user’s unfinished input, a navigation choice, or a computation over other values? Then I ask what should happen when the component unmounts, the route changes, the browser reloads, or the account changes.
Scope and lifetime are different. A value can be needed by several sibling components while still belonging only to one screen. Conversely, a small user object can have an application-wide session lifetime. “Several consumers” is a reason to share access; it does not automatically justify permanent global ownership.
| Question about the value | Likely owner | Architectural reason |
|---|---|---|
| Is it unfinished input used by this screen? | Local component or nearest shared parent | Short lifetime and direct editing |
| Does it identify the current session? | Session layer / Context | Shared identity and bootstrap lifecycle |
| Does the server own the saved record? | TanStack Query cache | Fetching, freshness, and mutation reconciliation |
| Should a link or browser history preserve it? | Route or URL search parameters | Navigation is part of its meaning |
| Can existing values determine it? | Computation | Avoid a second independently writable value |
| Is it a coordinated, long-lived client workflow? | Dedicated store, if justified | Explicit transitions across consumers |
This is a decision aid, not a rule that every value has exactly one physical representation. A server response can seed an editable form. What matters is whether the form is an intentional working copy or an accidental competing source of truth.
Persistence also deserves its own decision. Putting state in Context does not make it survive reloads. Putting a draft in localStorage would add retention requirements. Putting it in the URL would make it visible in links and history. I would choose those behaviors explicitly instead of inheriting them from whichever storage mechanism is convenient.
Current implementation: which owner supplies which state?
AuthProvider: user + bootstrap status ──> protected UI + computed role checks
URL: screen + ticket ID ────────────────> navigation + query identifier
Server records ──> TanStack Query ──────> ticket/message views + computed metrics
AI suggestion mutation ──> page handoff ──> editable local composer
Local forms ───────────────────────────> unsaved values + validation errors
3. Local State: Keep Ephemeral UI Close to Where It Changes
MessageComposer owns its content, validation error, and submission error with useState. Its pending indicator comes from the message mutation. That distinction is useful: the text is editable UI state, while the request lifecycle already has an owner.
Excerpt from MessageComposer.tsx:
const [content, setContent] = useState(initialContent);
const [fieldError, setFieldError] = useState<string | undefined>();
const [submitError, setSubmitError] = useState<string | null>(null);
const isSubmitting = createMessageMutation.isPending;
The component validates the text, submits trimmed content, and clears the input after the mutation succeeds. A failed submission sets an error without executing that success reset. The textarea is disabled while the request is pending. There is no need to put each keystroke into the message query cache: it is not yet a saved message.
The ticket creation form follows the same boundary. It owns title, description, field errors, and a submission error locally. After successful creation, it navigates to the returned ticket’s detail route. The unsaved form and the saved ticket are related, but they have different lifetimes and authorities.
Local does not mean “must live in the smallest possible component.” The AI reply handoff uses state in TicketDetailPage because the suggestion panel and composer are siblings. That parent is the nearest place that coordinates their interaction. Sharing within a screen does not require sharing across the whole application.
For a future dialog or temporary UI toggle, I would begin with the same ownership test: does another screen need to retain or control it? A form that must survive several routes may deserve a longer-lived owner. Component-local storage is a fit for these current forms, not a universal rule for every form.
4. Context: Give the Session a Shared Owner
AuthProvider owns a nullable user and authentication bootstrap loading. The user shape contains id, name, email, and a role of CUSTOMER, AGENT, or ADMIN. The Context exposes that user, login, logout, and isLoading. It does not contain ticket lists or message arrays. Context could expose server data in another design, but providing access does not by itself define fetching, freshness, or reconciliation. Here those responsibilities belong to the query layer.
The token has a separate persistence boundary: the storage service reads and writes access_token in localStorage. At startup, a stored token triggers fetchCurrentUser, which calls /auth/me. A successful response establishes the user in Context; a failure removes the token and clears the user.
Simplified excerpt from AuthProvider.tsx; the surrounding token check and loading handling are omitted:
try {
const currentUser = await fetchCurrentUser();
setUser(currentUser);
} catch {
storage.removeToken();
setUser(null);
}
Login has two steps across this boundary. The auth service saves the response token, and the login page passes the returned user to Context before navigating to the dashboard. Context’s login function itself sets the user and ends loading. A stored credential and the resolved user are complementary session representations, not two interchangeable user databases.
There is also a temporary login mutation result containing the response user. I would not describe authentication as having literally one object representation. The important current boundary is that protected routes and dashboard consumers read the session through Context rather than maintaining a second durable user store.
ProtectedRoute waits for bootstrap, redirects when there is no user, and otherwise renders its nested outlet. Axios reads the token for Bearer authentication. On a 401 it removes the token and redirects to login, except that /auth/me failures are left to bootstrap handling.
Role-derived booleans control which UI actions appear. For example, the detail page computes management access from the user’s role. That is presentation logic, not the final authorization boundary: the backend status route independently requires an agent or admin role. Context makes identity available; it does not make the browser authoritative over permissions.
5. TanStack Query: Server State Is Not Just Global Client State
A shared ticket array is only part of the problem. The frontend also needs to know whether it is loading, whether a request failed, which request parameters produced it, and what a successful mutation makes outdated. Sharing access across components does not transfer authority from the server to the browser. These concerns explain why ticket and message records belong in a query cache here.
The application’s QueryClient has these defaults:
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 1000 * 60,
refetchOnWindowFocus: false,
},
},
});
This config expresses a freshness policy; it does not promise continuous synchronization. The application uses a 60-second freshness window and disables window-focus refetching. That interval is not a polling schedule or proof that another agent’s changes will appear immediately. The ticket and message views expose loading/error states and retry or refresh behavior instead of treating cache presence as sufficient evidence of freshness.
Ticket keys distinguish list queries, including their parameter object, from detail queries keyed by ticket ID. Message keys distinguish conversations by ticket ID. useTicket and useMessages enable their queries only when the identifier is truthy.
Excerpt from useTickets.ts:
export function useTickets(params?: TicketListParams) {
return useQuery({
queryKey: ticketKeys.list(params),
queryFn: () => getTickets(params),
});
}
The same parameters define the cache identity and the API request. That alignment matters: two differently filtered requests should not silently share one list entry. It also creates a natural place for future normalized URL parameters to feed the fetching layer.
Mutation reconciliation is explicit. Ticket creation invalidates list queries. A status update patches status and updatedAt in an existing detail entry, then invalidates the lists and that detail. Message creation appends the returned message to the matching conversation cache on success.
The inspected list and thread render query data directly; they do not copy those arrays into component state or Context. This keeps the query result as the frontend’s shared representation of the server response. It does not eliminate every consistency question: different cache entries can represent overlapping records, so mutation handling still needs to account for the views affected by a change.
6. URL State: If Navigation Should Preserve It, Consider Putting It in the URL
Selected ticket identity is already navigation state. The active router declares /dashboard/tickets/:id, and the detail page derives its query identifier from that parameter:
const { id } = useParams();
const ticketId = id ?? "";
A ticket link therefore carries the selected identity without requiring a separate selected-ticket store. Browser navigation can change that identity through the pathname, and a direct link identifies which record to request, subject to authentication and access. This does not imply that browser history restores an unsent composer draft.
Current implementation. The list/dashboard behavior is narrower than a full URL-backed search interface. TicketsPage calls useTickets() without parameters and has no filter, search, sort, or pagination controls. The dashboard requests fixed parameters: page 1 and limit 100. The ticket parameter type supports status, priority, search, page, and limit, but that API capability is not an implemented navigation UI.
There is no useSearchParams or URLSearchParams usage in the inspected frontend source. The missing piece is a navigation design for future shareable list controls, rather than migrating existing component-only controls.
How I would evolve it. If list filters, search, and pagination become navigation-relevant, I would put their committed values in URL search parameters. A link such as /dashboard/tickets?status=OPEN&page=2 would describe which view to request after a refresh, when opening a shared link, or when moving back and forward through history. It would preserve the selection criteria, not freeze the server results. Parsing should validate enum values and normalize page numbers before producing query parameters. Changing a filter should deliberately decide whether to reset the page.
Search adds a useful distinction. Text currently being typed can remain local while a committed search becomes URL state. History updates should reflect meaningful navigation rather than creating an entry for every keystroke. Composer content, credentials, and transient errors have no comparable reason to appear in the URL.
7. A Real Workflow Shows Why State Changes Ownership
The message workflow makes the boundary concrete. Before submission, the text belongs to the composer. During submission, the mutation owns pending/error request state. After server success, the returned message becomes part of the conversation cache and the thread renders it.
Excerpt from useCreateMessage:
onSuccess: (newMessage) => {
queryClient.setQueryData<Message[]>(
messageKeys.ticket(ticketId),
(existing) => (existing ? [...existing, newMessage] : [newMessage]),
);
},
This is a cache update after success, not a speculative optimistic message insertion. The composer waits for mutateAsync before clearing text and invoking its callback. The server-returned record, rather than the raw input string, is what enters the message cache.
The callback increments a page-local messageRefreshToken. Despite its name, MessageThread uses it in a scroll effect alongside message count; it does not refetch messages because the token changed. That small detail matters when explaining ownership: the cache supplies conversation data, while this counter coordinates a UI effect.
Current workflow: when does a draft become a conversation record?
Ticket identity + query-backed view
-> separate suggestion request [mutation pending/error/result]
-> returned suggestion [candidate text; not sent]
-> click Use Reply [page-local handoff]
-> editable composer [local draft; human may edit]
-> explicit Send [message mutation]
success -> returned server message -> query cache -> rendered thread
clear composer + signal scroll
failure -> retain input + show submission error
The same send boundary applies to a reply typed without an AI suggestion.
The AI workflow starts with a separate mutation. ReplySuggestion requests generated text, displays mutation loading/error/result state, and derives a trimmed suggestion and a visibility boolean. It resets that mutation when the ticket ID changes. Generating a suggestion does not call the message creation hook.
Only clicking “Use Reply” invokes the parent callback. The page saves the suggestion as composerDraft and increments composerKey, remounting the composer so its local content initializes from that draft. The user can then edit the textarea and explicitly send through the ordinary message workflow.
This is an intentional working-copy boundary: generated text is a candidate, editable content is the human’s draft, and a successful send returns a conversation record. Similar strings across these stages do not have identical authority. I would preserve that separation even if the implementation later changes how siblings exchange the draft.
The current handoff also has a tradeoff. The page retains a seed value while the composer owns the live edited text. Using another suggestion remounts the composer and replaces its local input. I would consider an explicit replacement confirmation or a clearer draft ownership API if preserving unsent edits becomes a requirement. Neither protection is implemented by the shown handoff.
8. What About Redux?
Redux and Redux Toolkit are not used in this frontend: there are no matching source imports/usages or dependencies in its package manifest. The current flows use Context, queries, route identity, component state, and mutation state without a Redux store.
These ownership requirements do not automatically require Redux. A dedicated client store becomes worth considering when there is a coordination problem that the existing owners cannot express cleanly; this implementation alone cannot settle that decision for another product or future workflow.
I would consider a dedicated client store for a cross-feature workflow that stays active across routes, long-lived unsaved work spanning several screens, or coordinated UI transitions with explicit state-machine-like rules. For example, a future multi-ticket response workspace might need draft identity and transitions beyond a single composer. That is a hypothetical requirement, not an existing feature.
Even then, I would define what the store owns before adding it. It might own selected workspace items and unsaved drafts while querying authoritative ticket records separately. Copying every fetched ticket into the new store would introduce synchronization work without proving that the workflow needs it.
9. The Failure Modes I Would Watch For
Current limitations. Two session/cache boundaries deserve priority. First, ticket and message keys contain no account or session identifier. Second, logout removes the token and user but does not explicitly clear the query cache. The module-level QueryClient wraps AuthProvider, so the normal logout button does not recreate it.
Together, those choices leave an account-transition isolation gap. A later login in the same application lifetime can address keys used by the previous session. That is a source-supported isolation risk, not a reproduced data leak. Freshness configuration cannot substitute for session isolation.
How I would evolve it. I would design logout cleanup and account-scoped query identity together, including cancellation of requests that could complete after the session changes. Clearing existing data is only part of the lifecycle. Which requests may still write, and which identity the next request uses, also need explicit answers. This is proposed work, not current behavior.
Other current boundaries. The AI handoff replaces local input when another suggestion is used; draft-preservation behavior would need an explicit design. A separate constraint is the dashboard’s derived metrics. They count the returned page-1 list capped at 100 requested tickets. The computation marks possible truncation when the returned count reaches that limit. These are summaries of fetched records, not verified complete account totals. Derived state can be internally correct while its input is incomplete.
General failure modes. Copying query arrays into Context or local state and storing computed permissions independently create values that must stay aligned with their sources. Globalizing transient forms extends their lifetime and coordination surface. Keeping a navigation-relevant selection only in a component prevents the URL from describing it. These are design risks to watch for, not findings that all exist here. An intentional editable snapshot can justify duplication; an unedited mirror usually needs a stronger reason.
10. The State Ownership Model I Would Use as the Dashboard Grows
I would retain the existing division and strengthen the boundaries where behavior is incomplete. The target model below distinguishes retained choices from proposed additions.
| State | Target owner | Status and reason |
|---|---|---|
| Current user and bootstrap | Session Context | Current; shared session lifecycle |
| Credential persistence | Session storage service | Current localStorage mechanism; separate from user object |
| Tickets and messages | TanStack Query | Current query owner; proposed account scope and session cleanup |
| Selected ticket | URL route parameter | Current; identity belongs to navigation |
| Shareable filters and page | URL search parameters | Proposed; reproducible list views |
| Composer text and form errors | Local component state | Current; unfinished screen input |
| AI result and editable draft | Mutation result, then local workflow state | Current boundary; improve replacement semantics if needed |
| Permissions and dashboard summaries | Computed from session/query inputs | Current; respect backend authority and input completeness |
| Future cross-feature client workflow | Dedicated store only when justified | Proposed conditional choice |
Derived values already have useful examples. The detail page computes management permissions from the user role; the dashboard computes metrics and a five-item recent slice from query data with useMemo. The suggestion panel computes whether usable text is present from its mutation result and status. None needs an independently writable global field merely to make consumption convenient.
As the dashboard grows, I would review a new feature by walking through its transitions. What exists before the request? What changes on failure? What becomes authoritative on success? What should survive navigation, reload, or account change? Those questions expose ownership gaps earlier than asking where to put a new boolean.
11. Closing
My working model is to keep unfinished edits close to the UI, share session identity through a session layer, cache server records with explicit reconciliation, and let navigation own the choices a link should preserve. Compute values whose sources already exist. Add a client store when a concrete workflow needs a longer or broader lifetime.
For each new value, I would write down its source of truth, its consumers, and the event that ends its lifetime. Then I would decide whether navigation should preserve it and whether it can be computed from existing inputs. If those answers are unclear, choosing a more powerful store will only move the ambiguity. Resolve the ownership first; the tool choice follows.
