DEV Community

Cover image for Stateful vs. Stateless Frontends: Designing a Food Delivery App One State at a Time
Shayan Mirzaie
Shayan Mirzaie

Posted on

Stateful vs. Stateless Frontends: Designing a Food Delivery App One State at a Time

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,
  );
}
Enter fullscreen mode Exit fullscreen mode

and:

class BasketManager {
  private items: BasketItem[] = [];

  add(item: BasketItem) {
    this.items.push(item);
  }

  getTotal() {
    return calculateTotal(this.items);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

State ownership map for the frontend


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/
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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,
      },
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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[];
};
Enter fullscreen mode Exit fullscreen mode

It may be tempting to create another global state value:

type BasketStore = {
  basket: Basket;
  totalItems: number;
  subtotal: number;
};
Enter fullscreen mode Exit fullscreen mode

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 },
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Optimistic add-to-basket flow

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,
      );
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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,
    ),
  };
}
Enter fullscreen mode Exit fullscreen mode

This separation gives us a useful shape:

Stateless transformation
        +
Stateful orchestration
        +
Stateless transport
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This should not be represented as a collection of unrelated booleans:

// Invalid combinations are possible.
{
  isLoading: true,
  isConfirmModalOpen: true,
  isReplacingBasket: true,
  hasError: true,
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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 }),
}));
Enter fullscreen mode Exit fullscreen mode

Notice what is not in this store:

basketItems
basketSubtotal
restaurantMenu
foodAvailability
checkoutForm
searchFilters
Enter fullscreen mode Exit fullscreen mode

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:

Decision tree for placing frontend state

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Who owns this state?
  2. Is it canonical or only a cached snapshot?
  3. Can it be derived from something we already have?
  4. How long should it live?
  5. Should it survive refresh, tab closure, or device changes?
  6. Does it belong in the URL?
  7. Who is allowed to update it?
  8. What happens when two updates race?
  9. How does it become stale?
  10. 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?


Further reading

Top comments (0)