How would you design a multi-tab form with data persistence in React?

Intermediate20 min interview
Skills tested:
Centralized Form State ManagementLocal Storage PersistenceDebounced Auto-SaveValidation ArchitectureMulti-View Form DesignReact Performance Optimization

Advertisement

🧩 Scenario

You're designing a multi-tab form like: - Personal Info - Address - Preferences - Review & Submit Users should be able to: - Move between tabs freely - Keep data even if they refresh - Auto-save progress - Validate each tab independently - Submit aggregated data at the end

🧠 Architecture Walkthrough

Why Centralized State Is Non-Negotiable

A multi-tab form is fundamentally a single data entity being edited through multiple views. If each tab owns its own useState, the Review tab has no way to read the Personal Info tab's data without complex prop drilling or a shared parent that re-renders on every field change across all tabs.

The demo lifts all form data into a single data object at the MultiTabForm level, keyed by section name: { personal: {}, address: {}, preferences: {}, professional: {} }. This means any component including the Review tab can access the complete current data by just receiving data as a prop.

The updateData(section, field, value) function updates a single nested field using spread operators, so each keystroke produces a predictable immutable update without the component needing to know about the full form shape.

In a real application this state would typically live in Zustand or a React context to avoid prop drilling through deep component trees.

Debounced Auto-Save: Keeping the Input Responsive

Calling localStorage.setItem on every keystroke is a performance trap. localStorage is synchronous and blocks the main thread, which means every keystroke in a text field must wait for serialization and storage before the next character renders. For short strings this is imperceptible, but for large forms with nested objects, the serialization cost adds up and produces noticeable input lag.

The demo solves this by keeping data as raw unthrottled state that updates on every keystroke making the input feel instant while debouncedData is a derived value that only updates after the user has stopped typing for 500ms.

A useEffect watches debouncedData and writes to localStorage only when it changes. This decouples input responsiveness from storage I/O entirely, and the 500ms debounce means that even rapid typists only trigger one save per burst of typing rather than one per character.

Validation Timing: Per-Section vs. Full-Form

The demo uses a useFormValidation hook that contains validateSection, validateAllSections, and isValidSection as separate concerns. The distinction matters for UX. isValidSection runs silently and returns a boolean it is used to enable or disable the "Next" button without showing any error messages to the user.

validateAllSections is called only at final submission and populates the errors object that drives visible red error states. This two-track approach respects the principle that you should not scold a user for an empty field they have not yet visited.

The tab navigation indicators in TabNavigation show a red dot when errors[tab.key] is non-empty and a green checkmark when isValidSection returns true and no errors exist but neither appears until the user has actually triggered validation on that section.

💡 Key Code Explained

const [data, setData] = useState(() => {
  try {
    const saved = localStorage.getItem('multi-tab-form-data');
    return saved
      ? JSON.parse(saved)
      : {
          personal: {},
          address: {},
          preferences: {},
          professional: {},
        };
  } catch {
    return {
      personal: {},
      address: {},
      preferences: {},
      professional: {},
    };
  }
});

The lazy initializer function passed to useState runs only once on the component's first mount. This is the correct place to read from localStorage because it avoids calling localStorage.getItem on every subsequent render.

The try/catch around JSON.parse handles two failure cases: localStorage being disabled in private browsing mode (which throws a SecurityError on access), and corrupted JSON from a previous session crash.

Without the catch block, a single bad write to localStorage would permanently crash the form until the user manually clears storage. The fallback value is the empty form shape, which is identical to the initial state in both paths this consistency makes it easy to reason about what data always looks like.

const updateData = useCallback((section, field, value) => {
  setData((prev) => ({
    ...prev,
    [section]: {
      ...prev[section],
      [field]: value,
    },
  }));
  setSaveStatus('saving');
}, []);

The useCallback with an empty dependency array is intentional and correct here. updateData uses the functional form of setData which always receives the latest prev state, so there is no stale closure problem.

By memoizing updateData, the four form section components (PersonalInfoForm, AddressForm, etc.) can receive the same function reference across renders and avoid unnecessary re-renders themselves provided they are wrapped in React.memo.

The setSaveStatus('saving') call immediately before the debounced save fires gives the user visible feedback that their changes have been recognized, even before localStorage has been written. Without it, the "Auto-save enabled" indicator stays static while the user types, which feels like the auto-save is not working.

⚖️ Tradeoffs

ApproachProCon
Single lifted state + localStorage debounceSimple to implement, works offline, zero network dependencyData lost if localStorage fills up or is cleared; not shared across devices
Server-side draft persistenceSurvives device switches, no storage quota issuesRequires auth, adds API complexity, fails offline
URL-encoded form stateShareable links, survives browser crashes without storageURL length limits, sensitive data exposed in URLs and server logs

🎯 What Interviewers Actually Check

  • Uses a lazy useState initializer to read from localStorage on mount not inside a useEffect that runs after the first render, which causes a flash of empty form
  • Knows that localStorage.setItem is synchronous and explains why calling it on every keystroke is a problem
  • Distinguishes between "disable Next button silently" and "show validation errors" as two separate code paths
  • Resets to tab 0 and clears localStorage after successful submission not just on form clear
  • Mentions that the debounced save fires for the last value before the component unmounts, so a user who navigates away mid-type loses at most 500ms of input

❓ Follow-Up Questions

  1. The lazy useState initializer reads localStorage synchronously on the server during SSR in Next.js. What error does this throw and how do you fix it?
  2. A user has the same form open in two browser tabs simultaneously. Tab A saves draft A, Tab B saves draft B. When they close Tab B, whose data survives and how would you use the storage event to detect and reconcile concurrent edits?
  3. How would you write a test that verifies data is recovered from localStorage on remount after a simulated page refresh?
  4. The form currently auto-saves the entire data object on every debounce tick. At 50 fields this is wasteful. How do you implement field-level dirty tracking to save only changed sections?
  5. Your manager says the form must warn users with an "unsaved changes" browser dialog before they close the tab. Which browser API does this use, and what are its limitations in mobile browsers?

🎮 Live Demo

📝 Summary

A multi-tab form is architecturally a single entity split across multiple views, which means the state must live above all tabs in a single object rather than being fragmented across tab-local state.

The two most important implementation details are the lazy useState initializer for recovery which runs once on mount and reads from localStorage synchronously and the debounced auto-save that separates the raw state driving the UI from the throttled value that triggers storage writes.

Validation runs in two modes: a silent isValidSection check that gates tab navigation without showing error messages, and an explicit validateAllSections call at submission that populates visible error indicators.

Together these three decisions centralized state, debounced persistence, and two-mode validation give the user a form that feels responsive, never loses their work, and only surfaces errors at the right moments.

Frequently Asked Questions

Where should multi-tab form state live?

A global store (Zustand, Redux, or lifted React state) works best, since multiple tabs read/write shared data.

How do you persist data safely?

Use debounced saves to localStorage or server. Encrypt if data is sensitive.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement