State management in Umami codebase - Part 1.1
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.
Prerequisite
- State management in Umami codebase — Part 1.0
Approach
The approach we take is simple:
-
Pick a route, for example, https://cloud.umami.is/analytics/us/websites
-
Locate this route in Umami codebase.
-
Review how the state is managed.
-
We repeat this process for 3+ pages to establish a common pattern, see if there’s any exceptions.
In this part 1.1, we review the websites route and see what library is used to manage state, here it is the list of websites. We will find out what libraries Umami uses, how the files are structured, how the data flows to manage its state.
I reviewed the /websites route and found that the following files give us a clear picture about state management.

We will first review the code and then the underlying pattern. When you visit /websites on Umami, you see list of websites.

Our goal is to find out how this list of websites is stored. Is there a component state or application state or server cache state? let’s see.
WebsitesDataTable.tsx
You will find the following code in WebsitesDataTable.tsx
...
export function WebsitesDataTable({
userId,
teamId,
allowEdit = true,
allowView = true,
showActions = true,
}: {
userId?: string;
teamId?: string;
allowEdit?: boolean;
allowView?: boolean;
showActions?: boolean;
}) {
const { user } = useLoginQuery();
const queryResult = useUserWebsitesQuery({
userId: userId || user?.id, teamId
});
...
Here the queryResults is assigned the data fetched from the server. More info about useUserWebsitesQuery can be found in the API layer series.
And this queryResult is fed to the DataGrid and WebsiteTable components as shown below:
...
return (
<DataGrid query={queryResult} allowSearch allowPaging>
{({ data }) => (
<WebsitesTable
data={data}
showActions={showActions}
allowEdit={allowEdit}
allowView={allowView}
renderLink={renderLink}
/>
)}
</DataGrid>
...
WebsitesTable
You will find the following code in WebsitesTable.
import { DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { LinkButton } from '@/components/common/LinkButton';
import { useMessages, useNavigation } from '@/components/hooks';
import { SquarePen } from '@/components/icons';
export interface WebsitesTableProps extends DataTableProps {
showActions?: boolean;
allowEdit?: boolean;
allowView?: boolean;
renderLink?: (row: any) => ReactNode;
}
export function WebsitesTable({ showActions, renderLink, ...props }: WebsitesTableProps) {
const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation();
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{renderLink}
</DataColumn>
<DataColumn id="domain" label={formatMessage(labels.domain)} />
{showActions && (
<DataColumn id="action" label=" " align="end">
{(row: any) => {
const websiteId = row.id;
return (
<LinkButton href={renderUrl(`/websites/${websiteId}/settings`)} variant="quiet">
<Icon>
<SquarePen />
</Icon>
</LinkButton>
);
}}
</DataColumn>
)}
</DataTable>
);
}
This just renders the data passed via props.
Strategy used
We did not see any references to useState or libraries like Zustand, instead we saw useUserWebsitesQuery.ts
and this useUserWebsitesQuery.ts is defined as shown below:
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useUserWebsitesQuery(
{ userId, teamId }: { userId?: string; teamId?: string },
params?: Record<string, any>,
options?: ReactQueryOptions,
) {
const { get } = useApi();
const { modified } = useModified(`websites`);
return usePagedQuery({
queryKey: ['websites', { userId, teamId, modified, ...params }],
queryFn: pageParams => {
return get(
teamId
? `/teams/${teamId}/websites`
: userId
? `/users/${userId}/websites`
: '/me/websites',
{
...pageParams,
...params,
},
);
},
...options,
});
}
This returns usePagedQuery and this is defined as shown below
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import type { PageResult } from '@/lib/types';
import { useApi } from './useApi';
import { useNavigation } from './useNavigation';
export function usePagedQuery<TData = any, TError = Error>({
queryKey,
queryFn,
...options
}: Omit<
UseQueryOptions<PageResult<TData>, TError, PageResult<TData>, readonly unknown[]>,
'queryFn' | 'queryKey'
> & {
queryKey: readonly unknown[];
queryFn: (params?: object) => Promise<PageResult<TData>> | PageResult<TData>;
}): UseQueryResult<PageResult<TData>, TError> {
const {
query: { page, search },
} = useNavigation();
const { useQuery } = useApi();
return useQuery<PageResult<TData>, TError>({
queryKey: [...queryKey, page, search] as const,
queryFn: () => queryFn({ page, search }),
...options,
});
}
Cache strategy
We finally see a reference to Tanstack React Query as the import shown below:
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
Learn more about useQuery
There are no additional options passed and this is using React Query’s default client-side cache.
Default React Query Behavior:
-
staleTime: 0ms (data immediately considered stale)
-
cacheTime/gcTime: 5 minutes (cached data kept for 5 minutes)
-
refetchOnWindowFocus: true
-
refetchOnMount: true if data is stale
Cache Invalidation Strategy:
Notice the useModified hook in useUserWebsitesQuery:
The modified timestamp is included in the queryKey. When websites data changes, modified updates, creating a new cache key, effectively invalidating the old cache.
useModified
useModified is defined as shown below:
import { create } from 'zustand';
const store = create(() => ({}));
export function touch(key: string) {
store.setState({ [key]: Date.now() });
}
export function useModified(key?: string) {
const modified = store(state => state?.[key]);
return { modified, touch };
}
-
When
touch('websites')is called, it updates the Zustand state -
The
useModified('websites')hook subscribes to that state -
When the state changes,
modifiedgets a new timestamp -
React Query sees the
queryKeyhas changed:['websites', { ..., modified: OLD_TIME }]→['websites', { ..., modified: NEW_TIME }] -
React Query treats this as a completely different query and refetches
This way you dont need to import queryClient.invalidateQueries() everywhere.
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:
-
https://github.com/umami-software/umami/blob/master/src/app/(main)/websites/WebsitesDataTable.tsx
-
https://github.com/umami-software/umami/blob/master/src/app/(main)/websites/WebsitesTable.tsx
-
https://github.com/umami-software/umami/blob/master/src/components/hooks/usePagedQuery.ts#L6
-
https://tanstack.com/query/latest/docs/framework/react/reference/useQuery