The biggest client component in Nakodo is the brief editor: 859 lines holding a campaign's brief, its search keywords, which platforms it looks on, how outreach behaves, and the button that launches the whole thing. How it works describes what a brief is from the outside. From the inside it is a long form with a launch button at the bottom, which is the exact shape that breeds unsaved-changes bugs.
It is behind the login, so you cannot poke at it, and the app is honest about that:
curl -sI https://nakodo.app/campaigns | grep -i location
# location: /login?next=%2Fcampaigns
Two problems in this post. One is knowing whether what is on screen has been saved. The other is what happens when a button somewhere inside the form wants to leave the page.
The lie a boolean tells
The obvious shape is const [dirty, setDirty] = useState(false), set true on every edit and false in the save handler. It is wrong, and it is wrong in a way that only shows up when the network is slow enough for someone to keep typing.
Press save. The request goes with the draft as it was. While it is in flight, the person adds a keyword. The request returns, the handler sets dirty to false, and the app now claims the keyword is saved. It is not: it was typed after the payload left.
So the flag is not a flag, it is a comparison:
// `saved` is false from the first edit until a save of what's on screen. A
// save that finishes after more edits leaves them unsaved.
type EditorState = { draft: Draft; saved: boolean };
type EditorAction = { type: "edit"; change: Partial<Draft> } | { type: "saved"; draft: Draft };
function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case "edit":
return { draft: { ...state.draft, ...action.change }, saved: false };
case "saved":
return action.draft === state.draft ? { ...state, saved: true } : state;
}
}
Every edit replaces the draft object and clears the flag. The success action carries the draft it was about:
const markSaved = () => dispatch({ type: "saved", draft: d });
d is the draft from the render that called save, captured in the closure. If nothing changed in between, it is still the same object in state, === holds, and the form reads "Saved". If anything changed, the draft in state is a new object, the comparison fails and the reducer returns state untouched, so the form still says there are unsaved changes, because there are.
This works because every edit spreads into a new object. There is no deep compare, no serialisation, no isEqual from a utility library: reference identity is the correct test, as long as the only way to change a draft is to make a new one. That is the invariant the reducer buys, and it is worth more than the four lines it costs.
The browser warning hangs off the same flag:
// Leaving with unsaved edits asks first.
useEffect(() => {
if (saved) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [saved]);
The listener is only attached while there is something to lose, and the handler is one line: e.preventDefault() is now the whole API, with no returnValue assignment and no string that any browser would show.
And the save button is a readout of the state rather than a thing you press hopefully:
<Button variant={canLaunch ? "outline" : "default"} onClick={save} disabled={pending || saved || blocked}>
{saved ? "Saved" : pending ? "Saving..." : canLaunch ? "Save without launching" : "Save brief"}
</Button>
The button that wants to leave
The second problem arrived with the new plan choice at the end of setup. The review page offers a free week of Pro above the launch button, and taking it goes to Stripe Checkout. There are also links out to the pages that edit the proposal, the contact details and the reply settings.
Every one of those is a navigation away from a form that may have edits in it. The two obvious designs are both bad. Lifting all navigation up to the editor means the editor needs to know about every child that might want to leave. Passing a saveFirst callback down through the tree means every component in between knows about a concern that is none of its business, and the same buttons are used on the plan page, where there is nothing to save at all.
So it is a context, and the whole file is eleven lines:
"use client";
import { createContext, useContext } from "react";
// A page with edits not saved yet (the brief editor) lets a button that leaves
// it save them first: the button hands over what it does, and the page runs
// it once its edits are saved. Anywhere else it runs straight away.
const SaveFirst = createContext<(leave: () => void) => void>((leave) => leave());
export const SaveFirstProvider = SaveFirst.Provider;
export const useSaveFirst = () => useContext(SaveFirst);
The inversion is the point. The button does not ask "is there anything to save?" and it does not call a save function. It hands over what it was going to do, and something above it decides when that happens:
export function TrialButton({ back, from, ... }) {
const [pending, run] = usePlanChange(back);
const saveFirst = useSaveFirst();
return (
<Button
...
onClick={() => saveFirst(() => run(() => changePlanAction(TRIAL_PLAN, undefined, back, from)))}
>
The default context value is (leave) => leave(), so the identical button on the plan page, with no provider above it, runs immediately. Nothing checks for a provider, nothing branches on whether one exists, and there is no | null to handle: the no-op is the default implementation.
In the editor, the provider wraps exactly the subtree that contains those buttons:
<SaveFirstProvider value={saveThen}>{beforeLaunch}</SaveFirstProvider>
and saveThen is where the two halves of this post meet:
// Saves any edits here, then goes on: to a page that changes the outreach,
// or to Stripe from the plan choice (beforeLaunch). While Nakodo writes the
// proposal nothing is saved (see `blocked`), so leaving then asks first.
const saveThen = (then: () => void) =>
saved || prep.writing
? then()
: run(saveBrief, () => {
markSaved();
then();
});
const openPage = (href: string) => saveThen(() => router.push(href));
If the form is clean, go. If a background write is in progress, go without saving, and let the browser's own warning handle it, because saving on top of a write that is still landing is worse than asking. Otherwise save, mark saved, then go. The same function serves an internal router.push and a redirect out to Stripe, because both of them are just "a thing to do afterwards".
The partial failure
One more case, which is the one that would have produced a support ticket: save and launch is a single action, and the launch half can fail on its own.
const saveAndLaunch = () =>
run(
async () => {
const r = await saveAndLaunchAction(campaignId, current, chosen, launchOutreach, targeting ?? undefined);
if (!r.ok && r.briefSaved) markSaved(); // only the launch failed
return r;
},
(r) => {
markSaved();
router.push(`/campaigns/${campaignId}${r.welcome ? "?welcome=launched" : ""}`);
},
);
The action reports back whether the brief was written even when the launch was refused. Without that flag the editor would show the launch error and claim there are unsaved changes, so the next thing the person does is press save, which cannot help, and then wonder which of the two messages to believe. One boolean on the result turns it into a single honest sentence about the one thing that failed.
What generalises
"Saved" is not an event that happened, it is a relationship between two values: what the server has, and what is on screen. Modelling it as a comparison rather than a flag makes the slow-network case correct by construction instead of by vigilance.
And when something inside a form needs to leave, pass the behaviour upward, not the state downward. The eleven-line context did not just avoid prop drilling; it let the same button work in a page with a dirty form and a page without one, with no conditional anywhere.
The form itself is behind the login, but the thing it configures is described on how it works, and the free week it now offers is on the pricing page.
Top comments (0)