perf: reduce rerenders and async waterfalls in document tables

Parallelize user + team lookups in findDocuments via Promise.all,
dedupe redundant getTeamById call in the internal TRPC route,
stabilize useUpdateSearchParams callback with useCallback + refs,
memoize parsed search params in toolbar components, fix stale
columns memo deps in template documents table, and use replace:true
for search/filter URL updates to avoid history spam.
This commit is contained in:
ephraimduncan
2026-02-16 20:25:03 +00:00
parent 335fee09a9
commit 921e0a0de6
7 changed files with 128 additions and 79 deletions
@@ -56,8 +56,11 @@ export const TemplatePageViewDocumentsTable = ({
const team = useCurrentTeam();
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
const searchParamsString = searchParams.toString();
const parsedSearchParams = useMemo(
() => ZDocumentSearchParamsSchema.parse(Object.fromEntries(searchParams)),
[searchParamsString],
);
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
@@ -174,7 +177,7 @@ export const TemplatePageViewDocumentsTable = ({
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
}, [_, i18n, team?.url]);
return (
<div className="space-y-4">
@@ -36,9 +36,11 @@ export const DocumentsTableToolbar = ({
const query = searchParams.get('query') ?? '';
const period = searchParams.get('period') ?? '';
const statusParam = searchParams.get('status');
const senderIdsParam = searchParams.get('senderIds');
const selectedStatusValues = parseToStringArray(searchParams.get('status'));
const selectedSenderValues = parseToStringArray(searchParams.get('senderIds'));
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
const selectedSenderValues = useMemo(() => parseToStringArray(senderIdsParam), [senderIdsParam]);
const [searchTerm, setSearchTerm] = useState(query);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
@@ -56,8 +58,11 @@ export const DocumentsTableToolbar = ({
return;
}
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
}, [debouncedSearchTerm, query, searchTerm]);
updateSearchParams(
{ query: debouncedSearchTerm || undefined, page: undefined },
{ replace: true },
);
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
const { data: members } = trpc.team.member.getMany.useQuery(
{
@@ -118,7 +123,7 @@ export const DocumentsTableToolbar = ({
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchTerm('');
updateSearchParams({ query: undefined, page: undefined });
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
}}
>
<XIcon className="h-4 w-4" />
@@ -133,10 +138,13 @@ export const DocumentsTableToolbar = ({
counts={statusCounts}
showSearch={false}
onSelectedValuesChange={(values) => {
updateSearchParams({
status: toCommaSeparatedSearchParam(values),
page: undefined,
});
updateSearchParams(
{
status: toCommaSeparatedSearchParam(values),
page: undefined,
},
{ replace: true },
);
}}
/>
@@ -147,10 +155,13 @@ export const DocumentsTableToolbar = ({
selectedValues={selectedSenderValues}
showSearch
onSelectedValuesChange={(values) => {
updateSearchParams({
senderIds: toCommaSeparatedSearchParam(values),
page: undefined,
});
updateSearchParams(
{
senderIds: toCommaSeparatedSearchParam(values),
page: undefined,
},
{ replace: true },
);
}}
/>
)}
@@ -164,10 +175,13 @@ export const DocumentsTableToolbar = ({
onSelectedValuesChange={(values) => {
const nextPeriod = values[0];
updateSearchParams({
period: nextPeriod ?? undefined,
page: undefined,
});
updateSearchParams(
{
period: nextPeriod ?? undefined,
page: undefined,
},
{ replace: true },
);
}}
/>
@@ -25,9 +25,11 @@ export const TemplateDocumentsTableToolbar = () => {
const query = searchParams.get('query') ?? '';
const period = searchParams.get('period') ?? '';
const statusParam = searchParams.get('status');
const sourceParam = searchParams.get('source');
const selectedStatusValues = parseToStringArray(searchParams.get('status'));
const selectedSourceValues = parseToStringArray(searchParams.get('source'));
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
const selectedSourceValues = useMemo(() => parseToStringArray(sourceParam), [sourceParam]);
const [searchTerm, setSearchTerm] = useState(query);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
@@ -45,8 +47,11 @@ export const TemplateDocumentsTableToolbar = () => {
return;
}
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
}, [debouncedSearchTerm, query, searchTerm]);
updateSearchParams(
{ query: debouncedSearchTerm || undefined, page: undefined },
{ replace: true },
);
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(
() => [
@@ -131,7 +136,7 @@ export const TemplateDocumentsTableToolbar = () => {
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchTerm('');
updateSearchParams({ query: undefined, page: undefined });
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
}}
>
<XIcon className="h-4 w-4" />
@@ -145,10 +150,13 @@ export const TemplateDocumentsTableToolbar = () => {
selectedValues={selectedStatusValues}
showSearch={false}
onSelectedValuesChange={(values) => {
updateSearchParams({
status: toCommaSeparatedSearchParam(values),
page: undefined,
});
updateSearchParams(
{
status: toCommaSeparatedSearchParam(values),
page: undefined,
},
{ replace: true },
);
}}
/>
@@ -158,10 +166,13 @@ export const TemplateDocumentsTableToolbar = () => {
selectedValues={selectedSourceValues}
showSearch={false}
onSelectedValuesChange={(values) => {
updateSearchParams({
source: toCommaSeparatedSearchParam(values),
page: undefined,
});
updateSearchParams(
{
source: toCommaSeparatedSearchParam(values),
page: undefined,
},
{ replace: true },
);
}}
/>
@@ -174,10 +185,13 @@ export const TemplateDocumentsTableToolbar = () => {
onSelectedValuesChange={(values) => {
const nextPeriod = values[0];
updateSearchParams({
period: nextPeriod ?? undefined,
page: undefined,
});
updateSearchParams(
{
period: nextPeriod ?? undefined,
page: undefined,
},
{ replace: true },
);
}}
/>
@@ -22,7 +22,9 @@ export const TemplatesTableToolbar = () => {
const updateSearchParams = useUpdateSearchParams();
const query = searchParams.get('query') ?? '';
const selectedTypeValues = parseToStringArray(searchParams.get('type'));
const typeParam = searchParams.get('type');
const selectedTypeValues = useMemo(() => parseToStringArray(typeParam), [typeParam]);
const [searchTerm, setSearchTerm] = useState(query);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
@@ -40,8 +42,11 @@ export const TemplatesTableToolbar = () => {
return;
}
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
}, [debouncedSearchTerm, query, searchTerm]);
updateSearchParams(
{ query: debouncedSearchTerm || undefined, page: undefined },
{ replace: true },
);
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
const typeOptions = useMemo<DataTableFacetedFilterOption[]>(
() => [
@@ -91,7 +96,7 @@ export const TemplatesTableToolbar = () => {
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchTerm('');
updateSearchParams({ query: undefined, page: undefined });
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
}}
>
<XIcon className="h-4 w-4" />
@@ -105,10 +110,13 @@ export const TemplatesTableToolbar = () => {
selectedValues={selectedTypeValues}
showSearch={false}
onSelectedValuesChange={(values) => {
updateSearchParams({
type: toCommaSeparatedSearchParam(values),
page: undefined,
});
updateSearchParams(
{
type: toCommaSeparatedSearchParam(values),
page: undefined,
},
{ replace: true },
);
}}
/>
@@ -1,3 +1,5 @@
import { useCallback, useRef } from 'react';
import type { NavigateOptions } from 'react-router';
import { useSearchParams } from 'react-router';
@@ -7,20 +9,29 @@ type UpdateSearchParamsOptions = Pick<NavigateOptions, 'preventScrollReset' | 'r
export const useUpdateSearchParams = (defaultOptions: UpdateSearchParamsOptions = {}) => {
const [searchParams, setSearchParams] = useSearchParams();
return (params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
const searchParamsRef = useRef(searchParams);
searchParamsRef.current = searchParams;
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) {
nextSearchParams.delete(key);
} else {
nextSearchParams.set(key, String(value));
}
});
const defaultOptionsRef = useRef(defaultOptions);
defaultOptionsRef.current = defaultOptions;
setSearchParams(nextSearchParams, {
...defaultOptions,
...options,
});
};
return useCallback(
(params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
const nextSearchParams = new URLSearchParams(searchParamsRef.current?.toString() ?? '');
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) {
nextSearchParams.delete(key);
} else {
nextSearchParams.set(key, String(value));
}
});
setSearchParams(nextSearchParams, {
...defaultOptionsRef.current,
...options,
});
},
[setSearchParams],
);
};
@@ -16,6 +16,7 @@ export type PeriodSelectorValue = '' | 'all' | '7d' | '14d' | '30d';
export type FindDocumentsOptions = {
userId: number;
teamId?: number;
team?: Awaited<ReturnType<typeof getTeamById>>;
templateId?: number;
source?: DocumentSource | DocumentSource[];
status?: ExtendedDocumentStatus | ExtendedDocumentStatus[];
@@ -34,6 +35,7 @@ export type FindDocumentsOptions = {
export const findDocuments = async ({
userId,
teamId,
team: preloadedTeam,
templateId,
source,
status,
@@ -45,25 +47,19 @@ export const findDocuments = async ({
query = '',
folderId,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
let team = null;
if (teamId !== undefined) {
team = await getTeamById({
userId,
teamId,
});
}
const [user, team] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
}),
preloadedTeam ?? (teamId !== undefined ? getTeamById({ userId, teamId }) : null),
]);
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
@@ -37,8 +37,10 @@ export const findDocumentsInternalRoute = authenticatedProcedure
folderId,
};
let team: Awaited<ReturnType<typeof getTeamById>> | undefined;
if (teamId) {
const team = await getTeamById({ userId: user.id, teamId });
team = await getTeamById({ userId: user.id, teamId });
getStatOptions.team = {
teamId: team.id,
@@ -55,6 +57,7 @@ export const findDocumentsInternalRoute = authenticatedProcedure
findDocuments({
userId: user.id,
teamId,
team,
query,
templateId,
page,