When we hear the words stateful and stateless, many of us first think about backend systems.
Stateless APIs.
Sticky sessions.
Redis.
Horizontal scaling.
But the same distinction becomes just as important in frontend applications—especially after the product grows beyond a few pages and a couple of API calls.
Imagine opening a food delivery application like Snappfood.
You choose an address. Search for a restaurant. Apply a filter. Open a menu. Add food to your basket. Change the quantity. Enter a delivery note. Move to checkout. Return to the previous page.
Every one of these interactions changes something.
The difficult question is not whether the application has state. It obviously does.
The difficult question is:
Who should own each piece of state, how long should it live, and what is the source of truth?
That is where stateful and stateless design becomes an architectural decision—not just a React decision.
In this article, I want to build a simplified frontend architecture for a Snappfood-like product and make those decisions one by one.
The example is intentionally educational. It does not describe Snappfood's internal architecture or any proprietary implementation. The goal is to create a realistic model that is complex enough to expose the trade-offs.
First, what do stateful and stateless actually mean?
A system is stateful when its current behavior depends on information preserved from previous interactions.
A system is stateless when it can process the current input without remembering previous interactions internally.
Consider these two functions:
function calculateTotal(items: BasketItem[]) {
return items.reduce(
(total, item) => total + item.price * item.quantity,
0,
);
}
and:
class BasketManager {
private items: BasketItem[] = [];
add(item: BasketItem) {
this.items.push(item);
}
getTotal() {
return calculateTotal(this.items);
}
}
The first function is stateless. Give it the same input and it produces the same output. It does not remember the previous call.
The second object is stateful. The result of getTotal() depends on everything that happened before it.
Neither one is automatically better.
They solve different problems.
What does this mean on the backend?
HTTP itself is stateless. One request does not automatically know anything about the request that came before it.
That is why a request usually carries enough information to be understood independently:
POST /baskets/basket-42/items
Authorization: Bearer <token>
Content-Type: application/json
{
"foodId": "food-10",
"quantity": 1
}
The server can authenticate the request, identify the basket, validate the command, and return a response without relying on memory left inside one specific application instance.
This makes horizontal scaling easier:
Request 1 → API instance A
Request 2 → API instance C
Request 3 → API instance B
Any healthy instance should be able to handle the next request.
But there is an important distinction here:
A stateless API does not mean a stateless business domain.
A basket clearly has state. Orders have state. Users have state. Payment attempts have state.
The difference is that this state is stored in an explicit, durable owner such as a database or Redis—not hidden in the memory of a random API instance.
Stateless API process → Stateful database
This distinction becomes very useful when we move to the frontend.
A frontend cannot be completely stateless
A useful frontend reacts to history.
The user opened a modal.
The user selected an address.
The user typed a search query.
The user added two items to a basket.
Without state, every interaction would disappear immediately.
So our goal is not to remove state from the frontend.
Our goal is to prevent state from spreading everywhere.
State is not the problem. State without clear ownership is the problem.
The architecture I prefer is to keep most rendering, transformation, validation, and transport code stateless—and isolate state inside a small number of explicit owners.
The application we are going to build
Our simplified product has these user flows:
- Select a delivery address
- Search and filter restaurants
- Open a restaurant menu
- Add food to a basket
- Replace the basket when switching restaurants
- Change item quantities
- Complete checkout
I will use React and TypeScript in the examples, with TanStack Query for server state and a small client store only where cross-feature UI state is genuinely needed.
The exact libraries are not the point. The ownership boundaries are.
At the repository level, I would start with something like this:
apps/
customer-web/
vendor-panel/
support-panel/
packages/
features/
restaurant-search/
restaurant-menu/
basket/
checkout/
entities/
restaurant/
food/
basket/
order/
shared/
ui/
api/
validation/
formatting/
config/
This is a modular monorepo, not automatically a microfrontend architecture.
The packages give us ownership and dependency boundaries. They do not force us to deploy every feature separately.
That matters because organizational boundaries and runtime boundaries are different decisions.
1. Keep visual components stateless
Let us begin with a food card.
type FoodCardProps = {
name: string;
imageUrl: string;
price: number;
available: boolean;
quantityInBasket: number;
onAdd: () => void;
};
export function FoodCard({
name,
imageUrl,
price,
available,
quantityInBasket,
onAdd,
}: FoodCardProps) {
return (
<article>
<img src={imageUrl} alt="" />
<h3>{name}</h3>
<Price value={price} />
{quantityInBasket > 0 && (
<span>{quantityInBasket} in basket</span>
)}
<button disabled={!available} onClick={onAdd}>
Add
</button>
</article>
);
}
FoodCard does not fetch the menu.
It does not import the basket store.
It does not know whether the user is authenticated.
It receives data and emits an event.
Conceptually:
UI = render(props)
This makes the component reusable in restaurant menus, search results, recommendations, and promotional sections.
It also makes testing much smaller:
it("disables Add when the food is unavailable", () => {
render(
<FoodCard
name="Pizza"
imageUrl="/pizza.jpg"
price={250_000}
available={false}
quantityInBasket={0}
onAdd={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Add" }))
.toBeDisabled();
});
No router.
No API mock.
No global store setup.
This is one of the practical benefits of stateless design: the number of possible histories we need to reproduce becomes smaller.
2. Keep the API client stateless
Now we need to communicate with the basket backend.
export type AddBasketItemCommand = {
basketId: string;
foodId: string;
quantity: number;
// Used only to build the temporary optimistic snapshot.
optimisticPrice: number;
};
export const basketApi = {
get(basketId: string) {
return http.get<Basket>(`/baskets/${basketId}`);
},
addItem(command: AddBasketItemCommand) {
return http.post<Basket>(
`/baskets/${command.basketId}/items`,
{
foodId: command.foodId,
quantity: command.quantity,
},
);
},
};
This module receives an explicit command and returns a result.
It should not quietly hold the current basket:
// Avoid this mixed responsibility.
class BasketService {
currentBasket?: Basket;
async load(basketId: string) {
this.currentBasket = await http.get(`/baskets/${basketId}`);
}
}
The second design mixes at least three responsibilities:
- HTTP transport
- Cache
- Client state ownership
Now every consumer needs to understand when currentBasket was loaded, whether it is stale, and who is allowed to change it.
Calling something a “service” does not make those responsibilities disappear.
3. Treat fetched data as server state
The restaurant menu, current prices, item availability, basket, and orders belong to the server.
The browser only has a temporary snapshot of them.
That snapshot has a lifecycle:
loading → fresh → stale → refetching → fresh
↘ error
This is different from UI state such as whether a dialog is open.
For the basket, we can make the ownership explicit:
export const basketKeys = {
all: ["basket"] as const,
detail: (basketId: string) =>
[...basketKeys.all, basketId] as const,
};
export function useBasket(basketId: string) {
return useQuery({
queryKey: basketKeys.detail(basketId),
queryFn: () => basketApi.get(basketId),
staleTime: 15_000,
});
}
TanStack Query owns the browser cache.
The backend still owns the canonical basket.
That sentence is important because a cache is stateful, but it is not necessarily the source of truth.
If another tab, device, or backend process changes the basket, our cached value can become stale. The architecture must expect that.
4. Do not store derived state
Suppose our basket looks like this:
type Basket = {
id: string;
vendorId: string;
items: BasketItem[];
};
It may be tempting to create another global state value:
type BasketStore = {
basket: Basket;
totalItems: number;
subtotal: number;
};
But totalItems and subtotal can be calculated from basket.items.
export function getBasketSummary(basket: Basket) {
return basket.items.reduce(
(summary, item) => ({
totalItems: summary.totalItems + item.quantity,
subtotal:
summary.subtotal + item.price * item.quantity,
}),
{ totalItems: 0, subtotal: 0 },
);
}
If we store all three values independently, we create three things that must always change together.
Eventually one update path will forget one of them.
Now the basket badge says 3, the basket contains 4 items, and checkout calculates a third number.
Every duplicated state creates a synchronization problem we must solve forever.
Derived values should remain stateless calculations unless profiling proves that memoization is necessary.
And memoization is still not a second source of truth. It is a performance detail.
5. Put workflow state in a feature controller
Adding food to a basket is more than one API call.
We want the UI to respond immediately, send the command, handle failure, and reconcile with the server response.
The stateful part belongs in a feature-level controller or hook:
export function useAddBasketItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: basketApi.addItem,
async onMutate(command) {
const key = basketKeys.detail(command.basketId);
await queryClient.cancelQueries({ queryKey: key });
const previous =
queryClient.getQueryData<Basket>(key);
queryClient.setQueryData<Basket>(key, (basket) =>
basket
? addItemOptimistically(basket, command)
: basket,
);
return { previous };
},
onError(_error, command, context) {
queryClient.setQueryData(
basketKeys.detail(command.basketId),
context?.previous,
);
},
onSuccess(serverBasket, command) {
queryClient.setQueryData(
basketKeys.detail(command.basketId),
serverBasket,
);
},
});
}
And the optimistic transformation itself remains stateless:
export function addItemOptimistically(
basket: Basket,
command: AddBasketItemCommand,
): Basket {
const existing = basket.items.find(
(item) => item.foodId === command.foodId,
);
if (!existing) {
return {
...basket,
items: [
...basket.items,
{
foodId: command.foodId,
quantity: command.quantity,
price: command.optimisticPrice,
},
],
};
}
return {
...basket,
items: basket.items.map((item) =>
item.foodId === command.foodId
? {
...item,
quantity: item.quantity + command.quantity,
}
: item,
),
};
}
This separation gives us a useful shape:
Stateless transformation
+
Stateful orchestration
+
Stateless transport
The orchestration is stateful because it deals with time:
- Before the request
- During the request
- After success
- After failure
Trying to make this workflow completely stateless would not help. The workflow is inherently temporal.
The goal is to keep that temporal complexity in one visible place.
6. Model restaurant switching as a state machine
A food delivery basket often belongs to one restaurant.
Suppose the current basket contains food from vendor A, and the user tries to add food from vendor B.
Now we have a business decision:
idle
→ checking basket
→ waiting for confirmation
→ replacing basket
→ adding item
→ completed
This should not be represented as a collection of unrelated booleans:
// Invalid combinations are possible.
{
isLoading: true,
isConfirmModalOpen: true,
isReplacingBasket: true,
hasError: true,
}
Can the flow be loading, waiting for confirmation, replacing the basket, and failed at the same time?
Probably not.
A discriminated union makes the valid states explicit:
type AddFoodFlow =
| { status: "idle" }
| {
status: "awaiting-vendor-confirmation";
foodId: string;
nextVendorId: string;
}
| { status: "submitting"; foodId: string }
| { status: "failed"; message: string };
Now the UI cannot accidentally render contradictory states without TypeScript making the problem visible.
This is a case where stateful design is not only necessary—it becomes safer when we model it honestly.
There is another important rule here:
The frontend may predict a business rule for better UX, but the backend must still validate it.
The cached basket can be stale. Another tab may have replaced it. A mobile client may have changed it. The frontend check improves the interaction; it does not become authorization or canonical validation.
7. Search and filters belong in the URL
Now consider a restaurant search page:
/restaurants?q=pizza&sort=delivery-time&free-delivery=true
The search query, sort order, filters, and pagination are state.
But they are not ordinary component state.
Users expect to:
- Refresh the page without losing the result
- Share the URL
- Use browser Back and Forward
- Bookmark the current view
That makes the URL the natural owner.
export function useRestaurantFilters() {
const [params, setParams] = useSearchParams();
const filters = {
query: params.get("q") ?? "",
sort: params.get("sort") ?? "recommended",
freeDelivery: params.get("free-delivery") === "true",
};
function updateFilter(
key: string,
value: string | undefined,
) {
setParams((current) => {
const next = new URLSearchParams(current);
value ? next.set(key, value) : next.delete(key);
return next;
});
}
return { filters, updateFilter };
}
The dangerous design is to maintain the same filters independently in both URL state and a global store.
Then we need bidirectional synchronization:
URL → Store
Store → URL
Which one wins after navigation?
What happens during hydration?
What happens when the user presses Back?
The simplest synchronization strategy is often not to create the duplicate in the first place.
8. Keep form state inside the form boundary
Checkout has a different type of state:
type CheckoutForm = {
addressId: string;
recipientName: string;
phone: string;
deliveryNote: string;
paymentMethod: "online" | "cash";
};
This state changes frequently, may be invalid, and often matters only until submission.
It should usually be owned by the form rather than copied into the application store on every keystroke.
function CheckoutForm() {
const form = useForm<CheckoutForm>({
defaultValues: {
addressId: "",
recipientName: "",
phone: "",
deliveryNote: "",
paymentMethod: "online",
},
});
const submitOrder = useSubmitOrder();
return (
<form onSubmit={form.handleSubmit(submitOrder.mutate)}>
{/* controlled checkout fields */}
</form>
);
}
For a long multi-step checkout, we may persist a draft in sessionStorage or on the backend.
But persistence should answer a product requirement:
Should the draft survive navigation?
Should it survive refresh?
Should it survive closing the browser?
Should it be available on another device?
Each “yes” moves the state to a longer-lived owner.
We should not choose localStorage simply because it is available.
Storage is an architectural decision about lifetime, privacy, invalidation, and migration.
9. Use global client state for genuinely shared client concerns
Not every shared value belongs to the server or URL.
For example, the basket drawer may be opened from the header, a food card, or a recommendation section.
That is a reasonable small UI store:
type BasketUiStore = {
isDrawerOpen: boolean;
openDrawer: () => void;
closeDrawer: () => void;
};
export const useBasketUi = create<BasketUiStore>((set) => ({
isDrawerOpen: false,
openDrawer: () => set({ isDrawerOpen: true }),
closeDrawer: () => set({ isDrawerOpen: false }),
}));
Notice what is not in this store:
basketItems
basketSubtotal
restaurantMenu
foodAvailability
checkoutForm
searchFilters
Those values already have better owners.
The fact that several components need data does not automatically mean Redux, Zustand, or Context should own it.
“Shared” is not a storage strategy.
A practical state placement guide
When I am not sure where a piece of frontend state belongs, I ask these questions in order:
The short version is:
Can it be calculated? → Derive it
Does the server own it? → Query cache
Must the view be shareable? → URL
Is it a form draft? → Form state
Does one component use it? → Local state
Do several features use it? → Feature/app store
Must it survive the tab? → Browser or backend persistence
The final question is always:
What is the single source of truth?
Single source of truth does not mean putting all state in one global object.
It means each individual piece of state has one clear owner.
The complete ownership map
For our food delivery application, the result looks like this:
| State | Owner | Why |
|---|---|---|
| Search query | URL | Shareable and navigation-aware |
| Restaurant filters | URL | Must survive refresh and Back/Forward |
| Menu and availability | Query cache | Server-owned and potentially stale |
| Canonical basket | Backend | Shared business state |
| Basket snapshot | Query cache | Temporary browser representation |
| Guest basket ID | Cookie/local storage | Must survive refresh |
| Basket item count | Derived calculation | Already exists inside basket items |
| Basket drawer visibility | UI store | Shared client-only interaction |
| Selected food image | Local component state | One component needs it |
| Checkout inputs | Form state | Temporary, high-frequency edits |
| Checkout step | Route or workflow state | Depends on navigation requirements |
| Order status | Query cache or live subscription | Server-owned and changes over time |
This table is more important than the library choices.
We can replace Zustand with Redux.
We can replace TanStack Query with another server-state solution.
We can replace React Router with a framework router.
If ownership remains clear, the architecture still has a chance to remain understandable.
What do we gain from this architecture?
1. Smaller test surfaces
Pure functions and presentational components do not require the entire application environment.
We can test pricing rules, mapping, validation, and rendering without booting a router, query client, and global store for every test.
2. Fewer synchronization bugs
We do not keep the same fact in URL state, component state, query cache, and global state simultaneously.
Fewer copies mean fewer opportunities for disagreement.
3. Clearer feature ownership
The basket package owns basket workflows.
The API layer owns transport.
The query cache owns fetched snapshots.
The backend owns canonical business data.
This becomes increasingly important when multiple teams work in the same monorepo.
4. Better reusability
Stateless UI can be reused without importing the original page's hidden assumptions.
5. More predictable scaling
Explicit boundaries make it easier to split bundles, move a feature, introduce SSR, or later extract a microfrontend if the organization actually needs one.
The architecture does not make those changes free, but it reduces the number of hidden dependencies we discover during them.
What does it cost?
1. More boundaries and files
A FoodCard, feature controller, API module, query definition, and pure transformation are more pieces than one component that does everything.
For a small application, this can be unnecessary ceremony.
2. Ownership requires team discipline
The structure only helps if engineers preserve it.
If every urgent task bypasses the feature boundary and imports stores directly, the architecture slowly becomes theoretical.
3. Optimistic updates are difficult
Rollback, duplicate requests, race conditions, and server reconciliation require careful design.
The code feels fast to the user because the complexity moved into the workflow.
4. Server state is eventually inconsistent
The browser cache is always a snapshot. We need explicit freshness, invalidation, and refetch strategies.
5. There is still stateful code
We have not eliminated complexity.
We have concentrated it.
And concentrated complexity is only useful when the boundary remains understandable.
This architecture is not perfect
There is no universally correct frontend state architecture.
If the product is offline-first, local persistence becomes much more important.
If order tracking uses live events, we need subscription state and reconnection behavior.
If each feature is independently deployed, microfrontend runtime boundaries change how state can be shared.
If the backend returns incomplete mutation responses, invalidation may be safer than directly replacing the cache.
If the application is small, several layers shown here may be overengineering.
The purpose of this architecture is not to provide a template that everyone should copy.
It is to make one idea visible:
Good state architecture is less about choosing a state management library and more about choosing the correct owner, lifetime, and source of truth for each piece of information.
Libraries can make state easier to store.
They cannot decide whether that state should exist.
That decision is still engineering work.
A checklist I would use in design review
For every new state value, I would ask:
- Who owns this state?
- Is it canonical or only a cached snapshot?
- Can it be derived from something we already have?
- How long should it live?
- Should it survive refresh, tab closure, or device changes?
- Does it belong in the URL?
- Who is allowed to update it?
- What happens when two updates race?
- How does it become stale?
- How do we recover after failure?
If these questions have clear answers, the library choice usually becomes much easier.
If they do not, adding another store probably will not solve the real problem.
Final thought
A large frontend is naturally stateful.
The user moves through time, the network is asynchronous, and the server can change independently.
But that does not mean every component, service, and package should remember things.
Keep rendering stateless where possible.
Keep transformations pure.
Keep transport explicit.
Put temporal workflows inside clear feature boundaries.
Give every piece of state one owner.
The goal is not a stateless frontend.
The goal is a frontend where state cannot hide.
I would love to hear how you approach this in large applications:
Where does basket state live in your frontend?
And which state management decision became expensive only after the product grew?



Top comments (0)