Nakodo 支援面板中的 18 個 useState,以及重設狀態的 key prop
Eighteen useState calls in one help panel, and the key prop that throws them all away
Nakodo 的支援面板以 18 個 useState 管理多組狀態,並透過變更 key prop 重新掛載面板,重設全部狀態。面板會在滑鼠移入或鍵盤聚焦時預先載入;保持掛載亦可保留未送出的訊息。
The help button at the bottom left of every signed-in page in Nakodo opens a panel that does three jobs in one surface: it walks the written manual, it offers an assistant when the manual did not have it, and it opens a conversation with a person when that did not either.
src/components/support/panel.tsx is 818 lines, the fourth largest client component in the app (the results table, the proposal editor and the brief editor are bigger; its own code comment claiming second place is out of date). It holds eighteen useState calls and two refs. I want to defend that, and then show the four pieces of it that are not obvious.
Nine steps, and a back stack that is not the browser's
type Step = "home" | "topics" | "answer" | "ask" | "form" | "opened" | "solved" | "list" | "thread";
const go = (next: Step) => {
setStack((s) => [...s, step]);
setError(null);
setStep(next);
};
const back = () => {
setError(null);
setStep(stack[stack.length - 1] ?? "home");
setStack((s) => s.slice(0, -1));
};
A panel with history in it is tempting to wire into the router, so that the back button works. Do not. The reader did not navigate anywhere: they are still on their campaign, with a panel over it. Pushing history entries means the back button out of the panel is also the back button out of the page they were working on, and the first time someone presses it twice they lose their place in the app to leave a help panel.
So the stack is an array of steps in component state. The back arrow in the header renders only when stack.length > 0, which is the same condition that decides whether the header shows the subtitle "Answered by a person, by email", because that line belongs to the panel's front door and not to a step inside it. Escape closes the whole panel, with stopPropagation so the dialog under it (if the panel was opened from inside one) does not also close.
Eighteen states, grouped, and why they are not reducers or a machine
They fall into five groups, in the order the panel uses them: where in the manual you are, the assistant's turns, the conversation form, the list of your conversations, and the thread you opened. Every group is small, none of them has a transition that depends on another group's internals, and the panel's steps are decided by the step union rather than by combinations of booleans.
A reducer would turn eighteen useState into one object plus eighteen action types and a switch, which is more code for the same transitions. A state machine library would describe the nine steps precisely and then still need all of the data above as context. The honest reason this is a pile of hooks is that each piece is read in exactly one place and written in one or two, and the file's structure (manual, assistant, person, conversations, drawing it, each under its own banner comment) carries the organisation that a reducer would otherwise have to.
One of those states is worth more than its line count: message, the text someone has written to a person. It lives here rather than in the form step, so closing the panel mid-sentence and opening it again does not lose it. The widget keeps the panel mounted once it has ever been wanted, which is what makes that work.
The chunk arrives on hover, and the panel is never server rendered
const SupportPanel = dynamic(() => import("./panel").then((m) => m.SupportPanel), { ssr: false });
const want = () => setWanted(true);
...
<button type="button" onPointerEnter={want} onFocus={want} onClick={() => { want(); setOpen((o) => !o); }} aria-expanded={open} ...>
Nobody is waiting on a help panel while a page paints, so it is a separate chunk and it is not server rendered. But a chunk that starts downloading on click gives you a button that does nothing for 200ms. onPointerEnter and onFocus set wanted, which mounts the panel closed, so by the time the click lands the component is usually already there. Keyboard users get the same head start from onFocus, and a touch user gets the pointer enter that precedes the tap.
The panel renders null while closed, so keeping it mounted costs one render and buys the half-written message.
A window event, caught by the thing that is always mounted
Other parts of the app link into the panel: a campaign header can offer help about limits, a settings page can open the part of the manual about emails. That is a custom window event, and the listener is in the 89-line widget rather than in the panel:
useEffect(() => {
function asked(event: Event) {
setRequest((last) => ({ n: (last?.n ?? 0) + 1, of: (event as CustomEvent<SupportOpenRequest>).detail ?? {} }));
setWanted(true);
setOpen(true);
}
window.addEventListener(SUPPORT_OPEN_EVENT, asked);
return () => window.removeEventListener(SUPPORT_OPEN_EVENT, asked);
}, []);
The panel is a lazy chunk that may not exist yet when a page asks for it, so the listener has to live in the component that is always mounted. That is the whole reason the widget and the panel are two files.
Then the counter:
<SupportPanel key={request?.n ?? 0} ... />
n increments on every request, so the key changes, so React throws the old panel away and mounts a new one. This is the entire mechanism for "asking for the same part of the manual twice starts afresh rather than leaving you where you were". The alternative, an effect inside the panel that watches the request prop and calls eight setters to sync it, is the thing React's own docs call out as a smell, and it would have had to decide what to do about the half-written message, the assistant turns and the back stack one by one. A key is one line and gets all eighteen right.
That is also why the initial state is computed by a pure function rather than patched in afterwards:
const start = startingPoint(request);
const [step, setStep] = useState<Step>(start.step);
const [stack, setStack] = useState<Step[]>(start.stack);
startingPoint opens the panel on the assistant with the named section chosen and the line that page sent already in the transcript, seeds the stack with ["home"] so the back arrow leads somewhere instead of trapping the reader, and ignores a topic that does not belong to the named section, so an out-of-date deep link cannot file a conversation under the wrong heading. With no request it returns the front door.
The one rule: the gate must never be a dead end
A conversation with a person opens only after the assistant has had a go. That is deliberate and it is also the riskiest thing in the panel, because a gate with a broken gatekeeper is a wall.
const exhausted = turns.length >= SUPPORT_LIMITS.assistantTurns; // 2
const assistantFailed = turns.length === 0 && error !== null && step === "ask";
const leadWithPerson = exhausted || answered === false || assistantFailed;
A failed question counts as a go. If the assistant cannot be reached at all, the panel leads with the person instead of insisting on a step that cannot be completed. The three ways to reach a human are therefore: the assistant answered and said it did not get there, the assistant has used both of its turns, or the assistant could not be asked.
The failure path in ask() is the other half of that care:
const result = await askSupportAction({ question: asked, topic: topic?.id ?? null, history: turns });
setAsking(false);
if (!result.ok) {
setError(result.error);
setTranscript((t) => t.slice(0, -1));
setQuestion(asked);
return;
}
The question was appended to the transcript optimistically before the call, so a failure pops it off again and puts the text back in the box. Without the pop, the transcript that later travels with the conversation to a person would contain a question with no answer, and the reader would have to retype what they had just written.
Small things that make it feel like a panel and not a page
- The scroller is
aria-live="polite", and one effect pins it to the bottom on changes to[step, transcript, asking, thread, loading]. Whatever just arrived is what you see. -
role="dialog",tabIndex={-1}, and focus taken inside arequestAnimationFrameon open, withcancelAnimationFramein the cleanup so a panel closed in the same frame does not steal focus afterwards. - On a phone it is
inset-0with a backdrop and the launcher hides itself (max-sm:hidden); on a desktop it is a 23rem by 34rem card atbottom-18 left-4, capped withmax-h-[calc(100svh-7rem)]. The header and footer pad withenv(safe-area-inset-top)andenv(safe-area-inset-bottom). - Bottom left, because toasts live bottom right, and a help button underneath the thing telling you what just happened is no use.
- There are no illustrations in the panel, on purpose. It opens over whatever page you were on, that page may already have its one illustration, and the panel cannot know whether adding one would make it two.
The panel is behind sign-in, so there is nothing to click on from here, but the writing it serves is public: the same plain-answer style is what how it works and the guides are written in, and the limits it explains are the ones on the pricing page. Eighteen useState calls is not elegant. Losing someone's half-written message because a prop changed would be worse.
來源:DEV Community · dev.to