Draft + Save, not save-on-click: editing permissions without flooding the backend
Auto-saving every checkbox feels modern. For a permission editor where each write refreshes every member's session, it's wrong in three ways. Here is the small React hook that turns clicks into a draft and a diff, how it resets safely when server data changes, and the guard that stops admins from losing unsaved work.
The problem
Saving each permission checkbox immediately meant many API calls, a session refresh for every group member on every click, and no way to review a batch of changes.
The result
Admins edit a draft, see exactly what will be added and removed, and save once; unsaved changes are protected against closing the tab or navigating away.
Stack
The problem
In our auth admin panel, the most-used screen is the permission editor: one group, one application, and a list of permission checkboxes grouped by resource.
Finance · billing
invoices ☑ view ☑ create ☐ delete
payments ☑ view ☐ refund
reports ☐ view ☐ export
The tempting design is the one most modern UIs use: save on click. Tick a box, a request goes out, done. No save button, no "unsaved changes".
For this screen, that design is wrong for three concrete reasons.
1. The API needs two calls, not one. The backend has one endpoint to add permissions and another to remove them. A user who ticks three boxes and unticks two would produce five separate requests, each one a separate write.
2. Every write refreshes every member. Each permission change bumps a version number for every member of the group in that app, so their next request picks up the new rights. For a department of 40 people, five clicks means five rounds of "refresh everyone's session", including the intermediate states nobody intended, like the moment between removing "view" and adding "export".
3. Admins think in batches. "Finance should be able to export reports but not refund payments" is one decision made of several clicks. Admins want to set it up, look at it, and then apply it.
The details
What the screen needs to know
To support "edit, review, save", the screen needs:
- the original permissions (what the server holds now),
- the draft (what the admin has ticked so far),
- the diff: which keys were added and which were removed,
- whether there are any changes at all,
- how many permissions the group would hold after saving.
And two actions: discard the draft, or save it.
The subtle part: resetting the draft
After a successful save, the server data changes (the query is refetched). The draft must start again from what the server now holds. The same goes for a refetch triggered from elsewhere. But it must not reset while the admin is editing just because a component re-rendered.
The challenges
- Arrays change identity on every render. Comparing
originalKeysarrays by reference would reset the draft constantly. - Resetting in an effect causes a flash. An effect runs after render, so the screen would briefly show the stale draft against the new data.
- "Select all" in a section must update many keys in one state change.
- Leaving with unsaved changes must be caught in two very different ways: closing the tab, and clicking a link inside the app.
The solution
1. A draft hook
export function usePermissionDraft(originalKeys: string[]) {
// A stable string tells us when the saved permissions really changed:
// the array itself gets a new identity on every render.
const signature = originalKeys.join('|');
const [draft, setDraft] = useState<Set<string>>(() => new Set(originalKeys));
const [lastSignature, setLastSignature] = useState(signature);
// Reset DURING render, not in an effect: after a save or a refetch,
// the draft must start again from what the server now holds.
if (lastSignature !== signature) {
setLastSignature(signature);
setDraft(new Set(originalKeys));
}
const added = [...draft].filter((key) => !originalKeys.includes(key));
const removed = originalKeys.filter((key) => !draft.has(key));
function update(mutate: (next: Set<string>) => void) {
setDraft((previous) => {
const next = new Set(previous);
mutate(next);
return next;
});
}
return {
isChecked: (key: string) => draft.has(key),
toggle: (key: string, checked: boolean) =>
update((next) => (checked ? next.add(key) : next.delete(key))),
setMany: (keys: string[], checked: boolean) =>
update((next) => keys.forEach((k) => (checked ? next.add(k) : next.delete(k)))),
added,
removed,
hasChanges: added.length > 0 || removed.length > 0,
resultCount: draft.size,
discard: () => setDraft(new Set(originalKeys)),
};
}
A few choices worth explaining:
- A
Setfor the draft. Checking and toggling a key is constant time, and duplicates are impossible. - Immutable updates.
update()always creates a newSet, so React sees the change. Mutating the old set in place would update nothing on screen. addedandremovedare derived, not stored. They're computed from the draft and the original on every render, so they can never disagree with what's on screen.setManypowers the per-section "Select all / Clear" control with a single state update.
2. Why "reset during render" and not useEffect
The obvious way to reset the draft when the server data changes is:
// ❌ resets one render too late
useEffect(() => {
setDraft(new Set(originalKeys));
}, [signature]);
That runs after the render that received the new data. For one frame, the screen shows the old draft against the new original, and the diff (and the "unsaved changes" footer) briefly shows nonsense.
Instead, the hook stores the last signature it saw and, when it changes, updates state during render. React supports this pattern ("storing information from previous renders"): it immediately re-renders with the corrected state before anything is painted. No flash, no extra effect.
The signature (originalKeys.join('|')) is the other half of the trick. The array from the query is a new object on every render, but its signature only changes when the actual list of permissions changes. So the draft resets after a save or a refetch, and never because of an unrelated re-render.
3. Save is a diff, sent as two calls
The footer appears only when there are changes, and states them plainly:
+3 added · −2 removed · applies to 42 members · [ Discard ] [ Save ]
Saving sends exactly the diff:
save.mutate({ added: draft.added, removed: draft.removed });
// → POST …/permissions { keys: added } (only if added is not empty)
// → DELETE …/permissions { keys: removed } (only if removed is not empty)
At most two requests, whatever the number of clicks, and each member's session is refreshed for one final state instead of every intermediate one.
If the save would leave the group with zero permissions in the app, a confirmation explains the consequence before anything is sent (see "Designing an admin panel for dangerous actions").
4. Don't lose the draft: an unsaved-changes guard
A draft creates a new risk: losing it. There are two ways to leave the page, and they need two different defenses.
Closing or reloading the tab. The browser's beforeunload event lets the page ask for its native confirmation:
function onBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = '';
}
Clicking a link inside the app. Client-side navigation in Next.js doesn't trigger beforeunload. So the guard listens for clicks on links in the capture phase (before the router's own handler) and intercepts plain left-clicks on internal links:
function onClick(event: MouseEvent) {
const isPlainLeftClick =
event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.defaultPrevented;
if (!isPlainLeftClick) return; // let "open in new tab" through
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href');
if (!href || !href.startsWith('/')) return; // external links: not ours
if (href === window.location.pathname + window.location.search) return; // same page
event.preventDefault();
setPendingHref(href); // show our own "leave without saving?" dialog
}
document.addEventListener('click', onClick, true); // capture phase
Details that matter:
- Cmd/Ctrl/Shift-clicks are allowed through. Opening a link in a new tab doesn't leave the current page, so there's nothing to protect.
- External links are ignored, and same-page links too.
- The guard is only active while there are changes (
dirty), and both listeners are removed when it isn't. - The hook returns
pendingHref,leave()andstay(). "Leave" navigates with the router; "stay" closes the dialog and keeps the draft.
Lessons learned
- Auto-save is a choice, not a default. When each write has side effects for other people, batch the changes.
- Look at what one write costs. Here, one click meant refreshing 40 sessions, which is reason enough for an explicit save.
- Derive the diff, don't store it. Computed
added/removedcan never drift from the draft. - Use a signature to detect real data changes. Array identity changes every render; content doesn't.
- Adjust state during render when it depends on props. It avoids the one-frame flash of a
useEffectreset. - Send the minimum. A diff sent as at most two requests beats one request per click.
- A draft needs protection. Guard both tab closing (
beforeunload) and in-app navigation (capture-phase link clicks). - Respect power-user gestures. Never block Cmd-click "open in new tab".
FAQ
Should forms auto-save or have a Save button?
Auto-save works well for independent, low-impact changes. Use an explicit Save when changes are related, when each write has side effects (notifications, recalculations, session refreshes), or when users need to review a batch first.
How do I reset React state when props change without useEffect?
Keep the previous value of the prop in state, compare it during render, and update the state immediately when it changes. React re-renders with the new state before painting, avoiding the flash an effect would cause.
How do I warn users about unsaved changes in Next.js?
Use beforeunload for closing or reloading the tab, and intercept in-app link clicks (for example with a capture-phase click listener on internal anchors) to show your own confirmation before navigating.
Why use a Set for a list of selected checkboxes?
Lookups, additions and removals are constant time and duplicates are impossible. Just remember to create a new Set on each change so React detects the update.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk