Why your base44 multi-step form loses state
Your base44 multi-step form loses state because the AI agent scaffolds each step as a self-contained component with its own useState, and your Next button either changes routes or swaps in a fresh component tree that unmounts the previous step. When a step unmounts, React garbage-collects its local state and the user's input is gone. Browser back and forward navigation triggers the same reset. Page refresh wipes any in-memory state, including Context and Zustand stores without persistence. The base44 state loss multi-step form bug has three workable fixes that vary in effort. Lift all form state into a single parent wizard container that never unmounts. Move shared state into a Zustand or Context store at module scope. Persist data to URL searchParams or sessionStorage so refresh and navigation survive. Most production wizards need all three layers stacked.
You shipped a Base44 signup wizard last week. The user fills out their name and email on step one, clicks Next, and step two opens. They fill in company details. They click Back to fix the email. Step one is empty. They retype it, click Next again, and step two is empty too. They close the tab and never come back.
This is the failure mode that quietly kills onboarding completion in Base44 apps. The user is not blocked by an error message. The page does not crash. The wizard simply forgets what the user typed, over and over, until they give up. We see this in roughly seventy percent of multi-step flows we audit — the AI agent generated each step in isolation and never thought about the cross-step state contract.
The damage is severe and measurable. Onboarding completion rates drop twenty to forty percent in flows with mid-wizard resets. Checkout flows lose revenue directly. The user never files a bug; they just leave. The fix is mechanical once you understand the three root causes — treat them as one bug and you patch symptoms forever.
What causes base44 state loss in multi-step forms
There are three root causes that compound. Each requires a different fix layer. The AI agent typically ships at least two of them in any wizard it generates without explicit prompting.
1. Each step owns its own useState, so unmounting the step destroys the data. The most common variant. The agent generated each step as a self-contained component with its own form state. When the user clicks Next and the tree swaps in step two, React unmounts step one and garbage-collects every useState it owned. The user's data was never anywhere else. We see this in roughly sixty percent of broken Base44 wizards — the prompt asked for a step component, and a step component with its own state is the obvious-looking shape.
2. Step navigation uses route changes that tear down the parent tree. The Next and Back buttons call router.push('/signup/step-2'). Most Next.js route changes do not preserve component instances — the entire subtree under the route segment unmounts and a fresh one mounts. Any state inside that subtree dies with it, including a Zustand store created inside a hook in that tree. Route-change semantics are correct for general app navigation but wrong for a wizard, where the flow should feel like one continuous form. This pattern shows up in roughly forty percent of broken wizards.
3. Browser back and forward navigation triggers a full reset. Even if the wizard uses local step state with a stable parent, the browser back button typically leaves the wizard entirely. The browser interprets Back as the navigation event that brought the user to the wizard, so it returns to the previous page. Or if the wizard added history entries on each step transition, the browser returns to a stale URL that re-mounts the wizard from scratch with no state. Either way the user sees a reset that feels capricious — and the browser back button is the most reflexive navigation gesture there is.
A fourth contributor, less common but worth naming: the wizard parent uses a changing key prop on the step component, e.g. <Step key={currentStep} ... />. The agent intends the key to drive re-rendering; it actually drives destroy-and-recreate. State inside the step dies on every key change. About ten percent of broken wizards have this.
The causes interact. Per-step useState plus route-change navigation loses state on Next, Back, refresh, and browser back. Lifted state plus route-change navigation loses state on refresh and browser back. Lifted state with no routes but no persistence survives Next and Back but dies on refresh. Diagnose which combination you have before patching.
How to confirm base44 form state loss (reproduction)
The reproduction is mechanical. Fifteen minutes per wizard.
- Open the wizard in a fresh browser tab. Open React DevTools to the Components panel.
- Fill out step one with distinctive values —
firstname-step1,email-step1@test.com. Note exactly what you typed. - Click Next. Watch the component tree in DevTools as the navigation happens. Three patterns are possible.
- If the step one component disappears from the tree entirely and step two appears as a freshly mounted component, the wizard is unmounting steps. State held inside step one is gone. Note this — root cause is per-step state plus component swap.
- If the URL changed in the address bar during the Next click, the wizard uses route-change navigation. Even if the parent tree looks the same, route changes can unmount React subtrees and any state inside them is at risk. Note this — root cause is route-driven step transitions.
- Click browser back. Note where it takes you. If it leaves the wizard entirely, the wizard added history entries on each step. If it returns to step one of the wizard but the form is empty, you have state loss on top of working step navigation.
- From step two with data filled, refresh the page. Note what you see. If the wizard restarts from step one with everything empty, you have no refresh persistence. This is expected with in-memory state alone and tells you which persistence layer to add.
- Open the step components in your editor. Search each for
useStatecalls. EveryuseStatethat holds form data inside a step component is a state-loss vector. Note the count. - Open the Next and Back button handlers. If they call
router.push,useRouter().push, or any navigation API, the wizard uses route-change navigation. If they set a step number on a parent component, navigation is in-tree. - Open the wizard parent component. Look for a
keyprop on the step component. If it changes on each step, it forces a remount even if the parent stays alive.
Five minutes of reproduction plus ten minutes of code reading tells you which combination of root causes is in play. Skip the reading step and you will patch one layer while the others remain broken.
How to fix base44 state loss in multi-step forms — step-by-step
Three fix paths. They stack. A robust production wizard usually needs all three.
Path A: lift step state to a parent wizard container
If each step owns its own useState, the fix is to move all form state up one level into a parent that owns the wizard. Each step renders against shared state passed in as props (or read from a store, see Path B).
The before pattern, broken — note that name and email live inside Step1:
function Step1({ onNext }: { onNext: () => void }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
return (
<form onSubmit={(e) => { e.preventDefault(); onNext(); }}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button>Next</button>
</form>
);
}
When setStep(2) runs in the parent, the step === 1 branch becomes false, React unmounts Step1, and the name and email state is gone. When onBack re-renders Step1, a fresh instance mounts with empty initial state.
The after pattern, fixed:
// SignupWizard.tsx — all state lives here
type FormData = { name: string; email: string; company: string; role: string };
function SignupWizard() {
const [step, setStep] = useState(1);
const [data, setData] = useState<FormData>({ name: "", email: "", company: "", role: "" });
const update = (patch: Partial<FormData>) =>
setData((prev) => ({ ...prev, ...patch }));
return (
<div>
{step === 1 && <Step1 data={data} update={update} onNext={() => setStep(2)} />}
{step === 2 && <Step2 data={data} update={update} onBack={() => setStep(1)} onNext={() => setStep(3)} />}
{step === 3 && <Step3 data={data} onBack={() => setStep(2)} onSubmit={() => handleSubmit(data)} />}
</div>
);
}
// Step1.tsx — no local state, reads and writes shared
function Step1({ data, update, onNext }: { data: FormData; update: (p: Partial<FormData>) => void; onNext: () => void }) {
return (
<form onSubmit={(e) => { e.preventDefault(); onNext(); }}>
<input value={data.name} onChange={(e) => update({ name: e.target.value })} />
<input value={data.email} onChange={(e) => update({ email: e.target.value })} />
<button>Next</button>
</form>
);
}
The wizard parent never unmounts during step navigation. Form data lives in the parent's state and survives every Next and Back click. There is exactly one source of truth.
Two cautions. If the wizard parent itself sits inside a route segment that changes between steps, the parent will unmount and you are back to square one — keep step transitions inside a single stable parent the router never tears down. If you use key={currentStep} on the step component to drive re-rendering, remove it — that key forces a remount on every step change and defeats the lift.
This fix alone resolves the majority of state-loss reports. It does not survive page refresh, browser back across the wizard boundary, or shared links — for that, you need Paths B and C.
Path B: move shared form state into a Zustand or Context store
When your wizard has many fields, multiple components need to read form data, or you want devtools and persistence middleware, lift the state out of the React tree entirely into a store at module scope. Zustand is the cleanest fit for Base44 because it has no provider tree to wire up and the API surface is small.
import { create } from "zustand";
type FormData = { name: string; email: string; company: string; role: string };
type WizardState = {
step: number;
data: FormData;
setStep: (step: number) => void;
update: (patch: Partial<FormData>) => void;
reset: () => void;
};
const initialData: FormData = { name: "", email: "", company: "", role: "" };
export const useSignupWizard = create<WizardState>((set) => ({
step: 1,
data: initialData,
setStep: (step) => set({ step }),
update: (patch) => set((s) => ({ data: { ...s.data, ...patch } })),
reset: () => set({ step: 1, data: initialData }),
}));
Each step reads only the slice it needs and writes through the shared actions:
function Step1() {
const name = useSignupWizard((s) => s.data.name);
const update = useSignupWizard((s) => s.update);
const setStep = useSignupWizard((s) => s.setStep);
return (
<form onSubmit={(e) => { e.preventDefault(); setStep(2); }}>
<input value={name} onChange={(e) => update({ name: e.target.value })} />
<button>Next</button>
</form>
);
}
The store lives at module scope, outside any component instance. It survives any navigation that does not refresh the page — including route changes, because the module stays loaded. It does not survive a full page refresh — for that, see Path C.
Context is a workable substitute for Zustand when your wizard is small and you do not want a dependency. Wrap the wizard tree in a SignupWizardProvider whose useState holds the form data and an update callback, then expose those via useContext. Context only works as long as the provider stays mounted — place it above any route segment the wizard transitions across, never inside a step that unmounts. The Provider's useState is no different from the lifted-state pattern in Path A; it just gives consumers access without prop drilling.
Path C: persist form state to URL searchParams or sessionStorage
Path A and Path B survive in-tree navigation. Neither survives a page refresh. For checkout flows, signup wizards, and any flow longer than a minute, you need persistence to a storage layer that survives the page lifecycle.
Three storage targets each fit a different use case.
URL searchParams. Best for short flows where the user might share or bookmark mid-flow, and where field count is small. Shareable, deep-linkable, but visible in the address bar and limited in size:
"use client";
import { useSearchParams, useRouter } from "next/navigation";
function SignupWizard() {
const router = useRouter();
const params = useSearchParams();
const step = Number(params.get("step") ?? "1");
const data = {
name: params.get("name") ?? "",
email: params.get("email") ?? "",
};
const update = (patch: Partial<FormData>) => {
const next = new URLSearchParams(params);
for (const [k, v] of Object.entries(patch)) {
if (v) next.set(k, v); else next.delete(k);
}
router.replace(`?${next.toString()}`, { scroll: false });
};
// setStep follows the same pattern, writing "step" to the params
}
Use router.replace rather than push to avoid polluting browser history with a new entry on every keystroke. The URL updates as the user types and survives refresh, share, and even a tab close-and-reopen via bookmark.
sessionStorage. Best for checkout and onboarding flows where the user should not see the data in the URL and where state should clear when the tab closes. The Zustand persist middleware wires this up in three lines:
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
export const useSignupWizard = create<WizardState>()(
persist(
(set) => ({
step: 1,
data: initialData,
setStep: (step) => set({ step }),
update: (patch) => set((s) => ({ data: { ...s.data, ...patch } })),
reset: () => set({ step: 1, data: initialData }),
}),
{ name: "signup-wizard", storage: createJSONStorage(() => sessionStorage) }
)
);
The store now hydrates from sessionStorage on mount and writes back on every change. Refresh restores the same step and data. Closing the tab clears the state — usually what you want for a signup flow.
localStorage. Best for long wizards a user might abandon and resume tomorrow. Same Zustand pattern with localStorage instead of sessionStorage. Add an explicit "Start over" button so users can clear stale state intentionally.
Combining the paths
A production-grade Base44 wizard usually layers all three. Lift state to a single wizard container (Path A), but instead of useState use a Zustand store at module scope (Path B), and wrap the store in persist middleware pointed at sessionStorage (Path C). Each layer prevents a different failure mode. None is sufficient alone for a real flow.
Handle the browser back button explicitly
Even with all three paths in place, browser back can still bite. If the wizard tracks step number in store state and never adds history entries, browser back exits the wizard entirely. If the wizard pushes history entries on each step, browser back returns to a stale URL that may or may not rehydrate cleanly.
The robust pattern is to push a history entry on every step transition and listen for popstate to sync the store step back to the URL:
useEffect(() => {
const handler = () => {
const step = Number(new URLSearchParams(window.location.search).get("step") ?? "1");
useSignupWizard.getState().setStep(step);
};
window.addEventListener("popstate", handler);
return () => window.removeEventListener("popstate", handler);
}, []);
Browser back now returns to the previous step inside the wizard rather than exiting it. The persisted store keeps the data, and the URL stays in sync.
Re-ground the AI agent before adding any new step
The single highest-leverage habit is to prompt the agent against your existing wizard shape before asking it to add a new step. Paste the existing store definition, the existing parent container, and the existing step contract into the prompt. Without this, the agent will scaffold a fresh step with its own useState and reintroduce the bug you just fixed. We measure this regression in roughly half of follow-up prompts where the agent was not re-grounded. It is the part of the fix that prevents the cycle from repeating.
How long does it take to fix base44 form state loss?
Timeline varies by which root causes are present and how complex the wizard is.
- Lift state in a simple 3-step wizard with no routing: thirty to sixty minutes. The refactor is mechanical, the test surface is small.
- Replace route-change navigation with in-tree step state: one to three hours, depending on whether the route URLs are referenced elsewhere (analytics, deep links, marketing).
- Install Zustand and migrate to a store: two to four hours for a medium wizard. The persist middleware is another fifteen minutes. The bulk of the time goes to threading the store through each step.
- Add URL searchParams persistence to a shareable checkout flow: two to six hours, mostly handling URL encoding edge cases and address-bar flicker.
- Full hardening sweep across multiple wizards in one app: one to two days for an app with three or four wizards. Worth it if state loss has been a recurring complaint in your funnel.
For a fix-sprint engagement, we budget half a day to a day for a single wizard depending on size, and add a hardening pass that converts other wizards in the same app to the same pattern.
DIY vs hire decision
DIY this if: You have one wizard, you can identify which of the three root causes is in play, and you are comfortable refactoring component state. Path A alone — lifting state to a parent — is the most common single fix and resolves the majority of complaints. The refactor is mechanical once you understand the ownership model.
Hire help if: You have multiple wizards across the app, the state loss is hitting a checkout or signup flow that is currently leaking revenue, you have already tried adding optional chaining and default values without success, or you suspect the AI agent will reintroduce the bug on the next regeneration pass. The fix-sprint engagement covers diagnosis of all three root causes, refactors the wizard to a Zustand-plus-persistence pattern, sets the conventions the agent should follow going forward, and leaves your team with a documented state model. Most engagements ship a single wizard in two to four days and apply the same pattern across the app in the same week.
Need a tangle of multi-step flows audited?
If your Base44 app has a signup wizard that loses state on Next, a checkout that resets on refresh, and an onboarding flow the user can never finish — and you suspect all three share the same root causes — our fix-sprint engagement is built for this. We diagnose all four root causes across every wizard, refactor to lifted state with a Zustand store and sessionStorage persistence, set up the URL persistence for shareable flows, and document the state model so the next AI regeneration does not undo the work. Most engagements ship in three to five days and lift onboarding completion by ten to thirty percent within the first week of the new flow being live.
Start a fix-sprint engagement for state loss
Related problems
- Data loss on return to app — the broader pattern of Base44 losing user data across navigation events, of which mid-wizard state loss is the most common symptom.
- Base44 data binding undefined field — the sibling bug where the data exists but the binding is wrong, often confused with state loss but with a different root cause and fix path.
- AI agent regression loop breaks code — why the same wizard keeps breaking the same way across regeneration cycles, and how to interrupt the loop with explicit conventions.