Blog
State management in Umami codebase - Part 1.2

State management in Umami codebase - Part 1.2

Inspired by BulletProof React, I applied its codebase architecture concepts to the Umami codebase.

This article focuses only on the state management in Umami codebase.

Why learn this pattern?

Let’s be honest, your useState code is probably a mess. Mine was too.

Look, we’ve all written this:

const [websites, setWebsites] = useState([]);

const addWebsite = async (data) => {
  const newWebsite = await api.post('/websites', data);
  setWebsites([...websites, newWebsite]); 
};

We all start the same way, useState for everything. Throw in Context API when passing props gets annoying. Works fine until your app grows.

Then you’re debugging why:

  • Your app is slow because state is everywhere

  • The UI shows old data after mutations

  • Clicking fast causes weird race conditions

  • You have to manually refresh data in 10 places

Production apps like Umami don’t have these problems because they use different patterns.

Let me show you what I learned from studying their code.

Prerequisites

  1. State management in Umami codebase — Part 1.0

  2. State management in Umami codebase — Part 1.1

Approach

The approach we take is simple:

  1. Pick a route, for example, https://cloud.umami.is/analytics/us/websites

  2. Locate this route in Umami codebase

  3. Review how the state is managed

  4. Repeat this process for 3+ pages to establish a common pattern and identify exceptions

In Part 1.1, we reviewed the API layer in the websites route. In this article, we review the state management involved in creating a website.

WebsiteAddButton Component

You’ll find the add button at WebsiteAddButton.tsx. This add button lets you create a new website.

Let’s investigate what happens when you submit the form.

WebsiteAddButton.tsx

import { useToast } from '@umami/react-zen';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { WebsiteAddForm } from './WebsiteAddForm';

export function WebsiteAddButton(
  { teamId, onSave }: { teamId: string; onSave?: () => void }) {
  const { formatMessage, labels, messages } = useMessages();
  const { toast } = useToast();
  const { touch } = useModified();

  const handleSave = async () => {
    toast(formatMessage(messages.saved));
    touch('websites');
    onSave?.();
  };

  return (
    <DialogButton
      icon={<Plus />}
      label={formatMessage(labels.addWebsite)}
      variant="primary"
      width="400px"
    >
      {({ close }) => <WebsiteAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
    </DialogButton>
  );
}

This component renders a button that opens a dialog to add a new website.

handleSave

const handleSave = async () => {
  // 1. Show success toast  
  toast(formatMessage(messages.saved));  
  // 2. Invalidate websites cache (triggers refetch!)
  touch('websites');                     
  // 3. Call parent callback if provided
  onSave?.();                            
};

This is where the cache invalidation happens. When touch('websites') is called, it updates the Zustand store timestamp, which causes all queries using useModified('websites') (like useUserWebsitesQuery) to refetch.

We also discussed this in Part 1.1 in the settings/websitedata component. When touch is called, React Query invalidates the existing list of websites and fetches the new list with the latest addition. This is a clever way to handle query data invalidation.

The onSave?.() call isn't passed as a prop from the parent component WebsitesPage:

return (
  <PageBody>
    <Column gap="6" margin="2">
      <PageHeader title={formatMessage(labels.websites)}>
        <WebsiteAddButton teamId={teamId} />
      </PageHeader>
      <Panel>
        <WebsitesDataTable teamId={teamId} />
      </Panel>
    </Column>
  </PageBody>
);

WebsiteAddForm Component

You will find the below code in WebsiteAddForm.tsx

import { Button, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { DOMAIN_REGEX } from '@/lib/constants';

export function WebsiteAddForm({
  teamId,
  onSave,
  onClose,
}: {
  teamId?: string;
  onSave?: () => void;
  onClose?: () => void;
}) {
  const { formatMessage, labels, messages } = useMessages();
  const { mutateAsync, error, isPending } = useUpdateQuery('/websites', { teamId });

  const handleSubmit = async (data: any) => {
    await mutateAsync(data, {
      onSuccess: async () => {
        onSave?.();
        onClose?.();
      },
    });
  };

  return (
    <Form onSubmit={handleSubmit} error={error?.message}>
      <FormField
        label={formatMessage(labels.name)}
        data-test="input-name"
        name="name"
        rules={{ required: formatMessage(labels.required) }}
      >
        <TextField autoComplete="off" />
      </FormField>

      <FormField
        label={formatMessage(labels.domain)}
        data-test="input-domain"
        name="domain"
        rules={{
          required: formatMessage(labels.required),
          pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
        }}
      >
        <TextField autoComplete="off" />
      </FormField>
      <Row justifyContent="flex-end" paddingTop="3" gap="3">
        {onClose && (
          <Button isDisabled={isPending} onPress={onClose}>
            {formatMessage(labels.cancel)}
          </Button>
        )}
        <FormSubmitButton data-test="button-submit" isDisabled={false}>
          {formatMessage(labels.save)}
        </FormSubmitButton>
      </Row>
    </Form>
  );
}

Key Components of WebsiteAddForm

  1. useUpdateQuery Hook: Handles the mutation for creating a new website

  2. Form Validation: Uses React Hook Form validation rules for name and domain fields

  3. Success Callback Chain:

await mutateAsync(data, {
  onSuccess: async () => {
    onSave?.();   // Triggers cache invalidation via touch('websites')
    onClose?.();  // Closes the dialog
  },
});

The Complete Flow

  1. User clicks the “+ Add Website” button

  2. DialogButton opens with WebsiteAddForm

  3. User fills in name and domain, then submits

  4. handleSubmit calls mutateAsync (React Query mutation)

  5. On success:

  • onSave() is called → handleSave executes

  • touch('websites') updates the Zustand timestamp

  • All queries with useModified('websites') see the changed query key

  • React Query automatically refetches the websites list

  • Toast notification appears

  • Dialog closes

Key Takeaways

State Management Pattern:

  1. No global state for website lists

  2. Server state managed by React Query

  3. Cache invalidation via reactive query keys (Zustand timestamp)

  4. Automatic UI updates without manual state management

Benefits:

  1. Simple and predictable

  2. No need to manually update local state

  3. Automatic synchronization across all components using the same query

  4. Clean separation between UI state and server state

About me:

Hey, my name is Ramu Narasinga. I study codebase architecture in large open-source projects.

Email: ramu.narasinga@gmail.com

I spent 200+ hours analyzing Supabase, shadcn/ui, LobeChat. Found the patterns that separate AI slop from production code. Stop refactoring AI slop. Start with proven patterns. Check out production-grade projects at thinkthroo.com

References:

  1. WebsiteAddButton.tsx

  2. WebsitesPage.tsx

  3. WebsiteAddForm.tsx

We use cookies
We use cookies to ensure you get the best experience on our website. For more information on how we use cookies, please see our cookie policy.

By clicking "Accept", you agree to our use of cookies.

Learn more