I stopped putting everything in the app store
Defaulting form drafts, filters, and feature junk into global state throws away unmount cleanup. Local by default, with React and Vue sketches.
Last year I opened Project Settings, changed the display name halfway through, got pinged, and navigated to a completely different part of the app. I never hit Save. Three days later I came back to that same project and the half-edited name was still sitting in the input like I had never left.
Nobody built a “resume draft” feature. The field was wired to a global store slice next to session and theme, so leaving the route did nothing. The unfinished string just waited. Multiply that by filters that bleed across list pages, a side panel that still thinks the old row is selected, and a poll that keeps firing after you closed the screen that started it, and you get the habit I want to kill: treating the app store like a junk drawer with better TypeScript.
Modern React and Vue already give you a better default. When a route unmounts, local state goes with it. Effects tear down. Subscriptions stop. Memory that only belonged to that screen can actually get collected. That is not a niche optimization. It is one of the main reasons component trees beat the old “one giant page object” model.
Global state opts out of that deal. The store does not leave when the page does. If you park ephemeral UI there by default, you keep a long-lived bag of leftovers and call it architecture.
That default is an anti-pattern.
The bug is lifetime, not Redux
This is not a holy war about Redux, Pinia, Zustand, or Context. Those tools are fine when the lifetime matches the problem.
The bug is putting short-lived data into an app-lifetime container, then acting surprised when the UI lies.
You leave Settings mid-edit and come back to a half-finished form because nobody wrote the reset. You bounce between two list pages and the filters bleed because both write into the same global keys. A feature starts a poll or a socket in a root effect “so it is always ready,” then the feature is gone and the work is not. You delete the route folder in a cleanup PR and the store still imports types and actions for a product area that no longer ships.
Re-renders get blamed first because they are easy to profile. Ownership is the worse problem. Who is allowed to clear this? When? What happens on logout, on tenant switch, on “start over”? Local state answers those questions with the framework. Global state answers them with ceremony you will forget under deadline pressure.
What overuse looks like
I have done all of these.
Putting every controlled input in a root store so a parent three levels up can “see” values it never renders. Caching server lists forever in hand-rolled client global state with no invalidation story, then wondering why the badge counts are haunted. Parking modal payloads, draft side panels, and “current row” on the app shell because opening a dialog felt cross-cutting. Promoting state on the first speculative “we might need this on another page” instead of waiting for a second real consumer.
The failure mode is consistent: stale UI, ghost network work, tests that need a store factory for a button, two features coupled through keys nobody owns, and a codebase where deleting a page is a treasure hunt.
Local by default
The rule I want on a team now is blunt.
State starts next to the UI that needs it. Keep it component-local if one component owns it. Lift it to a route or feature shell if siblings under that feature share it. Make it app-global only when the lifetime is honestly app-wide, or when you are using a library whose whole job is cache lifetime and you accept that contract on purpose.
Shareable UI belongs in the URL when users should copy a link and see the same filters. Real business data belongs on the server. The client is not a second database you forgot to vacuum.
Here is the shape I reach for first in React: the draft dies when you leave the page.
// React Router style. Draft lives with the route.
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
export function ProjectSettingsPage() {
const { projectId } = useParams();
const [name, setName] = useState('');
const [dirty, setDirty] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
const project = await fetchProject(projectId!);
if (!cancelled) {
setName(project.name);
setDirty(false);
}
}
load();
return () => {
cancelled = true;
};
}, [projectId]);
return (
<form
onSubmit={async (event) => {
event.preventDefault();
await saveProject(projectId!, { name });
setDirty(false);
}}
>
<input
value={name}
onChange={(event) => {
setName(event.target.value);
setDirty(true);
}}
/>
<button type="submit" disabled={!dirty}>
Save
</button>
</form>
);
}
Navigate away and that draft is gone. Good. Most settings drafts should be gone. If you need a “resume unfinished edit” product feature, build that on purpose with an explicit draft API. Do not get it as an accident of useAppStore.
Vue gives you the same lifetime if you keep the state on the page, not in a Pinia module that outlives every visit.
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const name = ref('');
const dirty = ref(false);
let cancelled = false;
async function load(projectId: string) {
cancelled = false;
const project = await fetchProject(projectId);
if (!cancelled) {
name.value = project.name;
dirty.value = false;
}
}
watch(
() => route.params.projectId as string,
(projectId) => {
if (projectId) load(projectId);
},
{ immediate: true },
);
onUnmounted(() => {
cancelled = true;
});
async function onSubmit() {
await saveProject(route.params.projectId as string, { name: name.value });
dirty.value = false;
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-model="name" @input="dirty = true" />
<button type="submit" :disabled="!dirty">Save</button>
</form>
</template>
Same idea. The page owns the draft. Leaving the page is the cleanup policy.
Lift, then stop
When two siblings need the same ephemeral state, lift it to their nearest shared parent or a feature layout. That is still not “the app.”
// React: share inside a feature shell, not window.__STORE__
import {
createContext,
useContext,
useMemo,
useState,
type Dispatch,
type SetStateAction,
} from 'react';
import { Outlet } from 'react-router-dom';
type Filters = { query: string; status: 'all' | 'open' | 'done' };
const FiltersContext = createContext<{
filters: Filters;
setFilters: Dispatch<SetStateAction<Filters>>;
} | null>(null);
export function InboxLayout() {
const [filters, setFilters] = useState<Filters>({
query: '',
status: 'all',
});
const value = useMemo(() => ({ filters, setFilters }), [filters]);
return (
<FiltersContext.Provider value={value}>
<Outlet />
</FiltersContext.Provider>
);
}
export function useInboxFilters() {
const ctx = useContext(FiltersContext);
if (!ctx) {
throw new Error('useInboxFilters needs InboxLayout');
}
return ctx;
}
<!-- Vue: provide/inject under the feature layout -->
<script setup lang="ts">
import { provide, reactive } from 'vue';
export type Filters = { query: string; status: 'all' | 'open' | 'done' };
const filters = reactive<Filters>({ query: '', status: 'all' });
provide('inboxFilters', filters);
</script>
<template>
<RouterView />
</template>
Leave the inbox section and the provider unmounts. The filters reset unless you chose another home for them. That choice is the whole game.
If the filters should survive a refresh and stay shareable, put them in the query string. Then the back button and a pasted URL tell the truth without a secret client cache.
When global is still right
I am not arguing for prop-drilling theater or for hiding a session token in a leaf button.
Keep long-lived shared state when the lifetime is real:
- Auth and session identity, with a hard wipe on logout
- Theme, locale, and other shell preferences the chrome always needs
- A narrow host for toasts or a command palette
- A cart or multi-step flow you deliberately want to survive route changes, named and documented as surviving
- A server-state library (React Query, TanStack Query, Vue Query, and friends) where cache keys, stale times, and invalidation are the product, not a casual
setUsersdumped in a forever store
The difference is intent. Use global state because the problem is global, not because the store file was open and useState felt temporary.
If it must live high in the tree, write the teardown next to the creation. Cleared on logout. Cleared when onboarding exits. Cleared when the tenant changes. If you cannot finish that sentence, it is not ready to be global.
Before and after the junk drawer
The sharp version of the before looks like this. Every keystroke hits a store that outlives the route.
// Before: draft parked next to session forever
type AppState = {
session: Session | null;
projectSettingsDraft: { projectId: string; name: string } | null;
};
// somewhere in the page
store.setProjectSettingsDraft({ projectId, name: event.target.value });
// somewhere else, months later
// still reading projectSettingsDraft after the page is gone
The after keeps session global and leaves the draft on the page, or persists it through an API you could explain to a new hire without embarrassment.
// After: session is global. Draft is not.
type AppState = {
session: Session | null;
};
// page local
const [name, setName] = useState(initialName);
For data you used to mirror into Redux “so the navbar badge updates,” prefer a query cache and invalidate on mutation. You still get sharing. You also get a lifetime model someone already designed.
// React Query sketch
const { data: project } = useQuery({
queryKey: ['project', projectId],
queryFn: () => fetchProject(projectId),
});
const mutation = useMutation({
mutationFn: saveProject,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
},
});
That is shared client state with rules. It is not a junk drawer.
Habits that keep you honest
New feature state starts in the feature folder. Promote on the second real consumer, not the first imaginary one. When something stays global, name the lifetime in code or in a one-line comment you would not be embarrassed to ship. Prefer the URL for shareable list state. Prefer server-state libraries over eternal hand-rolled caches. If a PR only exists to reset store slices on every location.pathname change, that is a smell that the data never wanted to be global.
Deleting a feature should not require archaeology in stores/.
Not for you if
If you are building a rich document editor or a canvas where a long-lived client model is the product, you already accepted a big in-memory world. Own it with structure and teardown. Do not pretend useState in a toolbar replaces it.
If your team already runs a disciplined store with slices, owners, and logout paths that actually run, you are not my villain. Keep going. This post is aimed at the default where every new bit of UI grows another root key because that is how the last project started.
If the pain is really backend consistency, no amount of local React state will save you.
What I want out of the default
I want the framework’s unmount behavior back as the normal case.
Local state is not immature. It is honest about lifetime. Global state is a deliberate escape hatch for things that should outlive a page. When we reverse that, we throw away automatic cleanup, confuse ownership, and ship apps that remember the wrong things at the wrong times.
I stopped putting everything in the app store. The store got smaller. The features got easier to delete. The weird “why is this still here” bugs got quieter. That trade is worth the occasional prop or feature-level context.