Blog
State Management in Umami codebase - Part 1.3

State Management in Umami codebase - Part 1.3

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

  3. State management in Umami codebase — Part 1.2

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 state management in the websites route. In Part 1.2, we reviewed the state management involved in creating a website. In this article, we review how the state is managed in creating a goal.

What is a Goal in Umami?

Umami Goals is a crucial tool in providing valuable insights into how well your website is meeting its objectives. With clear metrics and visualizations, our goals insights translates user behavior into actionable steps towards improvement.

Learn more about Goal in Umami.

I reviewed the code around creating goal and the following are used to create a goal

  1. GoalsPage.tsx

  2. GoalAddButton.tsx

  3. GoalEditForm.tsx

  4. useUpdateQuery

GoalsPage

GoalsPage is defined as shown below:

'use client';
import { Column, Grid } from '@umami/react-zen';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useReportsQuery } from '@/components/hooks';
import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton';

export function GoalsPage({ websiteId }: { websiteId: string }) {
  const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' });
  const {
    dateRange: { startDate, endDate },
  } = useDateRange();

  return (
    <Column gap>
      <WebsiteControls websiteId={websiteId} />
      <SectionHeader>
        <GoalAddButton websiteId={websiteId} />
      </SectionHeader>
      <LoadingPanel data={data} isLoading={isLoading} error={error}>
        {data && (
          <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
            {data.data.map((report: any) => (
              <Panel key={report.id}>
                <Goal {...report} startDate={startDate} endDate={endDate} />
              </Panel>
            ))}
          </Grid>
        )}
      </LoadingPanel>
    </Column>
  );
}

The GoalsPage component serves as the container where users can view their existing goals and initiate creating new ones. Let's look at what's relevant for the goal creation flow.

<SectionHeader>
  <GoalAddButton websiteId={websiteId} />
</SectionHeader>

The only prop it needs is websiteId.

GoalAddButton

GoalAddButton is defined as shown below:

import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { GoalEditForm } from './GoalEditForm';

export function GoalAddButton({ websiteId }: { websiteId: string }) {
  const { formatMessage, labels } = useMessages();

  return (
    <DialogTrigger>
      <Button variant="primary">
        <Icon>
          <Plus />
        </Icon>
        <Text>{formatMessage(labels.goal)}</Text>
      </Button>
      <Modal>
        <Dialog
          aria-label="add goal"
          title={formatMessage(labels.goal)}
          style={{ minWidth: 400, minHeight: 300 }}
        >
          {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
        </Dialog>
      </Modal>
    </DialogTrigger>
  );
}

It’s just a button that opens a modal with a form. But look at how cleanly it handles the modal state.

Here’s what you don’t see, something quite common that you and I would use:

const [isOpen, setIsOpen] = useState(false);

<Button onClick={() => setIsOpen(true)}>Add Goal</Button>
{isOpen && <Modal onClose={() => setIsOpen(false)}>...</Modal>}

Instead, they’re using DialogTrigger from their UI library:

import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
...
<DialogTrigger>
  <Button variant="primary">
    <Icon><Plus /></Icon>
    <Text>{formatMessage(labels.goal)}</Text>
  </Button>
  <Modal>
    <Dialog>
      {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
    </Dialog>
  </Modal>
</DialogTrigger>

All the actual “create goal” logic lives in GoalEditForm. Let's look at that next, because that's where the real state management happens. 

See there’s no methods in this component and passed down, avoid props passing as much as possible.

GoalEditForm

GoalEditForm is defined as shown below:

import {
  Button,
  Column,
  Form,
  FormButtons,
  FormField,
  FormSubmitButton,
  Grid,
  Label,
  Loading,
  TextField,
} from '@umami/react-zen';
import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
import { ActionSelect } from '@/components/input/ActionSelect';
import { LookupField } from '@/components/input/LookupField';

export function GoalEditForm({
  id,
  websiteId,
  onSave,
  onClose,
}: {
  id?: string;
  websiteId: string;
  onSave?: () => void;
  onClose?: () => void;
}) {
  const { formatMessage, labels } = useMessages();
  const { data } = useReportQuery(id);
  const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);

  const handleSubmit = async (formData: Record<string, any>) => {
    await mutateAsync(
      { ...formData, type: 'goal', websiteId },
      {
        onSuccess: async () => {
          if (id) touch(`report:${id}`);
          touch('reports:goal');
          onSave?.();
          onClose?.();
        },
      },
    );
  };

  if (id && !data) {
    return <Loading placement="absolute" />;
  }

  const defaultValues = {
    name: '',
    parameters: { type: 'path', value: '' },
  };

  return (
    <Form onSubmit={handleSubmit} error={error?.message} defaultValues={data || defaultValues}>
      {({ watch }) => {
        const type = watch('parameters.type');

        return (
          <>
            <FormField
              name="name"
              label={formatMessage(labels.name)}
              rules={{ required: formatMessage(labels.required) }}
            >
              <TextField autoFocus />
            </FormField>
            <Column>
              <Label>{formatMessage(labels.action)}</Label>
              <Grid columns="260px 1fr" gap>
                <Column>
                  <FormField
                    name="parameters.type"
                    rules={{ required: formatMessage(labels.required) }}
                  >
                    <ActionSelect />
                  </FormField>
                </Column>
                <Column>
                  <FormField
                    name="parameters.value"
                    rules={{ required: formatMessage(labels.required) }}
                  >
                    {({ field }) => {
                      return <LookupField websiteId={websiteId} type={type} {...field} />;
                    }}
                  </FormField>
                </Column>
              </Grid>
            </Column>

            <FormButtons>
              <Button onPress={onClose} isDisabled={isPending}>
                {formatMessage(labels.cancel)}
              </Button>
              <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
            </FormButtons>
          </>
        );
      }}
    </Form>
  );
}

This is where you actually create (or edit) a goal. Let’s focus on what matters for the creation flow. 

const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);

This hook handles the API call. When creating a new goal, the URL is just /reports. When editing, it's /reports/${id}.

You get back:

  • mutateAsync -function to submit the form

  • error - any errors from the API

  • isPending - loading state

  • touch - this is the key to automatic data refresh

const handleSubmit = async (formData: Record<string, any>) => {
  await mutateAsync(
    { ...formData, type: 'goal', websiteId },
    {
      onSuccess: async () => {
        if (id) touch(`report:${id}`);
        touch('reports:goal');  // ← This is crucial
        onSave?.();
        onClose?.();
      },
    },
  );
};

When you submit the form:

  1. Send the data with mutateAsync

  2. On success, call touch('reports:goal')

  3. Close the modal with onClose()

That touch('reports:goal') line is why the goals list automatically updates.

Remember useReportsQuery({ websiteId, type: 'goal' }) from GoalsPage? When you call touch('reports:goal'), that query refetches. Your new goal appears without manually updating any state.

In part 1.1, I talked about how touch invalidates the client side data and refretches using useModified hook. Check it out.

You don’t see this:

const [goals, setGoals] = useState([]);
const newGoal = await api.post('/reports', data);
setGoals([...goals, newGoal]);

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. https://docs.umami.is/docs/goals

  2. https://github.com/umami-software/umami/blob/master/src/app/(main)/websites/%5BwebsiteId%5D/(reports)/goals/GoalsPage.tsx

  3. https://github.com/umami-software/umami/blob/master/src/app/(main)/websites/%5BwebsiteId%5D/(reports)/goals/GoalAddButton.tsx

  4. https://github.com/umami-software/umami/blob/master/src/app/(main)/websites/%5BwebsiteId%5D/(reports)/goals/GoalAddButton.tsx

  5. https://github.com/umami-software/umami/blob/master/src/app/(main)/websites/%5BwebsiteId%5D/(reports)/goals/GoalEditForm.tsx#L17

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