fix: use Tanstack Query for Keyword Research (#150)

* keywords: use query cache for research requests

* keywords: simplify research request dedupe
This commit is contained in:
Ben Senescu 2026-05-05 14:11:28 -04:00 committed by GitHub
parent 2fc568f73c
commit db1a1d723f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 223 additions and 255 deletions

View File

@ -1,4 +1,5 @@
import { useRef, useState } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
@ -9,7 +10,6 @@ import type {
KeywordSource,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
import type { KeywordResearchRow } from "@/types/keywords";
type AddSearchFn = (
keyword: string,
@ -17,130 +17,153 @@ type AddSearchFn = (
locationName: string,
) => void;
export function useKeywordResearchData(addSearch: AddSearchFn) {
const [rows, setRows] = useState<KeywordResearchRow[]>([]);
const [hasSearched, setHasSearched] = useState(false);
const [lastSearchError, setLastSearchError] = useState(false);
const [lastResultSource, setLastResultSource] =
useState<KeywordSource>("related");
const [lastUsedFallback, setLastUsedFallback] = useState(false);
const [lastSearchKeyword, setLastSearchKeyword] = useState("");
const [lastSearchLocationCode, setLastSearchLocationCode] = useState(
DEFAULT_LOCATION_CODE,
);
const [researchError, setResearchError] = useState<string | null>(null);
const [researchMutationError, setResearchMutationError] =
useState<unknown>(null);
const [searchedKeyword, setSearchedKeyword] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Sequence token so a stale fetch (e.g. user fired a second search before
// the first resolved) can't overwrite state that belongs to a newer one.
const requestSeqRef = useRef(0);
const beginSearch = (seedKeyword: string, locationCode: number) => {
setResearchError(null);
setResearchMutationError(null);
setHasSearched(true);
setLastSearchError(false);
setSearchedKeyword(seedKeyword);
setLastSearchKeyword(seedKeyword);
setLastSearchLocationCode(locationCode);
setIsLoading(true);
};
const resetResearch = () => {
setRows([]);
setHasSearched(false);
setLastSearchError(false);
setLastResultSource("related");
setLastUsedFallback(false);
setLastSearchKeyword("");
setLastSearchLocationCode(DEFAULT_LOCATION_CODE);
setResearchError(null);
setResearchMutationError(null);
setSearchedKeyword("");
setIsLoading(false);
};
const runSearch = async (
input: {
type KeywordResearchQueryInput = {
projectId: string;
keywords: string[];
keywordInput: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
},
handlers?: {
onSuccess?: (seedKeyword: string, rows: KeywordResearchRow[]) => void;
onError?: () => void;
},
) => {
const seedKeyword = input.keywords[0] ?? "";
const languageCode = getLanguageCode(input.locationCode);
const requestSeq = ++requestSeqRef.current;
const isStale = () => requestSeqRef.current !== requestSeq;
};
try {
const result = await researchKeywords({
data: {
keywords: input.keywords,
type KeywordResearchRequest = {
projectId: string;
keywords: string[];
seedKeyword: string;
locationCode: number;
languageCode: string;
resultLimit: ResultLimit;
mode: KeywordMode;
};
const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000;
function parseSearchKeywords(value: string) {
return value
.split(/[\n,]/)
.map((keyword) => keyword.trim())
.filter(Boolean);
}
function buildKeywordResearchQueryKey(request: KeywordResearchRequest | null) {
return request
? [
"keywordResearch",
request.projectId,
request.keywords,
request.locationCode,
request.languageCode,
request.resultLimit,
request.mode,
]
: ["keywordResearch", "idle"];
}
export function useKeywordResearchData(
input: KeywordResearchQueryInput,
addSearch: AddSearchFn,
) {
const keywords = useMemo(
() => parseSearchKeywords(input.keywordInput),
[input.keywordInput],
);
const request = useMemo<KeywordResearchRequest | null>(() => {
const seedKeyword = keywords[0] ?? "";
if (!seedKeyword) return null;
return {
projectId: input.projectId,
keywords,
seedKeyword,
locationCode: input.locationCode,
languageCode,
languageCode: getLanguageCode(input.locationCode),
resultLimit: input.resultLimit,
mode: input.mode,
};
}, [
input.locationCode,
input.mode,
input.projectId,
input.resultLimit,
keywords,
]);
const queryKey = useMemo(
() => buildKeywordResearchQueryKey(request),
[request],
);
const queryKeyString = JSON.stringify(queryKey);
const researchQuery = useQuery({
queryKey,
queryFn: () => {
if (!request) {
throw new Error("Keyword research query ran without request params");
}
return researchKeywords({
data: {
projectId: request.projectId,
keywords: request.keywords,
locationCode: request.locationCode,
languageCode: request.languageCode,
resultLimit: request.resultLimit,
mode: request.mode,
},
});
if (isStale()) return;
setResearchError(null);
setResearchMutationError(null);
setRows(result.rows);
setLastResultSource(result.source);
setLastUsedFallback(result.usedFallback);
captureClientEvent("keyword_research:search_complete", {
location_code: input.locationCode,
search_mode: input.mode,
result_count: result.rows.length,
},
enabled: request !== null,
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
retry: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
if (seedKeyword) {
addSearch(
seedKeyword,
input.locationCode,
LOCATIONS[input.locationCode] || "Unknown",
);
}
const handledSuccessKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!request || !researchQuery.isSuccess || !researchQuery.data) return;
if (handledSuccessKeyRef.current === queryKeyString) return;
handledSuccessKeyRef.current = queryKeyString;
handlers?.onSuccess?.(seedKeyword, result.rows);
} catch (error) {
if (isStale()) return;
setLastSearchError(true);
setRows([]);
setResearchMutationError(error);
setResearchError(getStandardErrorMessage(error, "Research failed."));
handlers?.onError?.();
} finally {
if (!isStale()) setIsLoading(false);
}
};
captureClientEvent("keyword_research:search_complete", {
location_code: request.locationCode,
search_mode: request.mode,
result_count: researchQuery.data.rows.length,
});
addSearch(
request.seedKeyword,
request.locationCode,
LOCATIONS[request.locationCode] || "Unknown",
);
}, [
addSearch,
queryKeyString,
request,
researchQuery.data,
researchQuery.isSuccess,
]);
const hasSearched = request !== null;
const rows = hasSearched ? (researchQuery.data?.rows ?? []) : [];
const researchError =
hasSearched && researchQuery.isError
? getStandardErrorMessage(researchQuery.error, "Research failed.")
: null;
return {
rows,
hasSearched,
lastSearchError,
lastResultSource,
lastUsedFallback,
lastSearchKeyword,
lastSearchLocationCode,
lastSearchError: hasSearched && researchQuery.isError,
lastResultSource:
researchQuery.data?.source ?? ("related" as KeywordSource),
lastUsedFallback: researchQuery.data?.usedFallback ?? false,
lastSearchKeyword: request?.seedKeyword ?? "",
lastSearchLocationCode: request?.locationCode ?? DEFAULT_LOCATION_CODE,
researchError,
researchMutationError,
searchedKeyword,
isLoading,
beginSearch,
resetResearch,
runSearch,
researchMutationError: researchQuery.error,
searchedKeyword: request?.seedKeyword ?? "",
isLoading: hasSearched && researchQuery.isPending,
researchQuery,
retryResearch: researchQuery.refetch,
};
}

View File

@ -81,10 +81,7 @@ function KeywordResearchContent({
Go to Billing
</Link>
) : (
<button
className="btn btn-sm"
onClick={() => controller.onSearch()}
>
<button className="btn btn-sm" onClick={controller.retrySearch}>
Try again
</button>
)}

View File

@ -17,7 +17,6 @@ import type { SortDir, SortField } from "@/client/features/keywords/components";
import {
buildKeywordSearchKey,
getNextSortParams,
parseKeywordInput,
useSaveAndExportActions,
} from "./keywordControllerActions";
import {
@ -45,6 +44,7 @@ export function useKeywordResearchController(
const state = useKeywordControllerState(input);
const controlsForm = state.controlsForm;
const setSearchParams = state.setSearchParams;
const retryResearch = state.retryResearch;
const onSearch = useCallback(
(overrides?: Partial<{ keyword: string; locationCode: number }>) => {
@ -61,6 +61,10 @@ export function useKeywordResearchController(
[controlsForm],
);
const retrySearch = useCallback(() => {
void retryResearch();
}, [retryResearch]);
const handleSearchSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();
@ -124,6 +128,7 @@ export function useKeywordResearchController(
removeHistoryItem: state.removeHistoryItem,
researchError: state.researchError,
researchMutationError: state.researchMutationError,
retrySearch,
resetFilters: state.resetFilters,
rows: state.rows,
searchedKeyword: state.searchedKeyword,
@ -191,66 +196,41 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
lastSearchLocationCode,
researchError,
researchMutationError,
researchQuery,
searchedKeyword,
isLoading,
beginSearch,
resetResearch,
runSearch,
} = useKeywordResearchData(addSearch);
retryResearch,
} = useKeywordResearchData(
{
projectId: input.projectId,
keywordInput: input.keywordInput,
locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
},
addSearch,
);
const setSearchParams = useKeywordSearchParams();
const saveMutation = useKeywordSaveMutation(input.projectId);
// Tracks the parameters used for the most recent search trigger so the
// URL-driven effect below doesn't re-fire after the form-submit path
// already kicked off a search for the same params.
const lastTriggerKeyRef = useRef<string | null>(null);
const activeSearchKey = input.keywordInput.trim()
? buildKeywordSearchKey({
keyword: input.keywordInput,
locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
})
: null;
const triggerSearch = useCallback(
(params: {
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
}) => {
const keywords = parseKeywordInput(params.keyword);
if (keywords.length === 0) return;
const previousSearchKeyRef = useRef<string | null>(null);
const handledSerpSearchKeyRef = useRef<string | null>(null);
lastTriggerKeyRef.current = buildKeywordSearchKey(params);
uiState.setSelectedKeyword(null);
const clearActiveKeywordResult = useCallback(() => {
clearSelection();
uiState.setSelectedKeyword(null);
setSerpKeyword(null);
beginSearch(keywords[0] ?? "", params.locationCode);
void runSearch(
{
projectId: input.projectId,
keywords,
locationCode: params.locationCode,
resultLimit: params.resultLimit,
mode: params.mode,
},
{
onSuccess: (seedKeyword, nextRows) => {
if (nextRows.length === 0) {
setSerpKeyword(null);
return;
}
setSerpKeyword(seedKeyword);
setSerpPage(0);
},
},
);
},
[
beginSearch,
clearSelection,
input.projectId,
runSearch,
setSerpKeyword,
setSerpPage,
uiState,
],
);
}, [clearSelection, setSerpKeyword, setSerpPage, uiState]);
const controlsForm = useKeywordControlsForm(
{
@ -269,63 +249,39 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
kLimit: value.resultLimit === 150 ? undefined : value.resultLimit,
mode: value.mode === "auto" ? undefined : value.mode,
});
// Trigger immediately so re-submitting the same query (URL unchanged)
// still refetches. The dedup ref prevents the URL effect below from
// double-firing in the typical (URL-changes) case.
triggerSearch({
keyword: value.keyword,
locationCode: value.locationCode,
resultLimit: value.resultLimit,
mode: value.mode,
});
},
);
// URL-driven search trigger. Fires when the user lands on a shareable URL
// (direct link, cmd+click on a history item, browser back/forward) so the
// page reproduces the search those params describe without a form submit.
// When the URL is cleared (no `q`), the page resets to the recent-searches
// empty state so the "Recent searches" Link works without an extra handler.
// The URL is the source of truth for paid keyword research queries. This
// effect only resets UI state around a new query key; TanStack Query owns the
// actual fetch, cache, dedupe, and error lifecycle.
useEffect(() => {
const trimmed = input.keywordInput.trim();
if (activeSearchKey === previousSearchKeyRef.current) return;
previousSearchKeyRef.current = activeSearchKey;
handledSerpSearchKeyRef.current = null;
if (trimmed.length === 0) {
if (lastTriggerKeyRef.current === null) return;
lastTriggerKeyRef.current = null;
resetResearch();
clearSelection();
uiState.setSelectedKeyword(null);
setSerpKeyword(null);
setSerpPage(0);
if (!activeSearchKey) {
clearActiveKeywordResult();
return;
}
const urlKey = buildKeywordSearchKey({
keyword: input.keywordInput,
locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
});
if (urlKey === lastTriggerKeyRef.current) return;
clearActiveKeywordResult();
}, [activeSearchKey, clearActiveKeywordResult]);
triggerSearch({
keyword: input.keywordInput,
locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
});
useEffect(() => {
if (!activeSearchKey || !researchQuery.isSuccess) return;
if (handledSerpSearchKeyRef.current === activeSearchKey) return;
handledSerpSearchKeyRef.current = activeSearchKey;
setSerpKeyword(rows.length > 0 ? searchedKeyword : null);
setSerpPage(0);
}, [
clearSelection,
input.keywordInput,
input.keywordMode,
input.resultLimit,
locationCode,
resetResearch,
activeSearchKey,
researchQuery.isSuccess,
rows.length,
searchedKeyword,
setSerpKeyword,
setSerpPage,
triggerSearch,
uiState,
]);
const { filteredRows, activeFilterCount } = useKeywordFiltering({
@ -349,7 +305,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
return {
activeFilterCount,
activeSerpKeyword,
beginSearch,
clearSelection,
controlsForm,
filteredRows,
@ -366,10 +321,9 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
mobileTab: uiState.mobileTab,
overviewKeyword,
removeHistoryItem,
resetResearch,
researchError,
researchMutationError,
runSearch,
retryResearch,
resetFilters,
rows,
searchedKeyword,

View File

@ -1,5 +1,6 @@
import * as React from "react";
import { Link, useLocation } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import {
ChevronDown,
ChevronsUpDown,
@ -35,47 +36,40 @@ export function AuthenticatedAppLayout({
const location = useLocation();
const [drawerOpen, setDrawerOpen] = React.useState(false);
const setupModalRef = React.useRef<HTMLDivElement | null>(null);
const [isSeoApiKeyConfigured, setIsSeoApiKeyConfigured] = React.useState<
boolean | null
>(null);
const [seoApiKeyStatusError, setSeoApiKeyStatusError] = React.useState(false);
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
React.useState(false);
const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
const seoApiKeyStatusQuery = useQuery({
queryKey: ["seoApiKeyStatus"],
queryFn: () => getSeoApiKeyStatus(),
enabled: shouldCheckSeoApiKeyStatus,
});
const isSeoApiKeyConfigured = shouldCheckSeoApiKeyStatus
? (seoApiKeyStatusQuery.data?.configured ?? null)
: null;
const seoApiKeyStatusError =
shouldCheckSeoApiKeyStatus && seoApiKeyStatusQuery.isError;
React.useEffect(() => {
if (location.pathname === BILLING_ROUTE) {
setSeoApiKeyStatusError(false);
setIsSeoApiKeyConfigured(null);
if (!shouldCheckSeoApiKeyStatus) {
setShowMissingSeoApiKeyModal(false);
return;
}
let cancelled = false;
const checkSeoApiKeyStatus = async () => {
try {
const result = await getSeoApiKeyStatus();
if (cancelled) return;
setSeoApiKeyStatusError(false);
setIsSeoApiKeyConfigured(result.configured);
if (!result.configured) {
setShowMissingSeoApiKeyModal(true);
}
} catch {
if (cancelled) return;
setSeoApiKeyStatusError(true);
setIsSeoApiKeyConfigured(null);
if (seoApiKeyStatusQuery.isError) {
setShowMissingSeoApiKeyModal(false);
return;
}
};
void checkSeoApiKeyStatus();
return () => {
cancelled = true;
};
}, [location.pathname]);
if (!seoApiKeyStatusQuery.isSuccess) return;
setShowMissingSeoApiKeyModal(!seoApiKeyStatusQuery.data.configured);
}, [
location.pathname,
seoApiKeyStatusQuery.data,
seoApiKeyStatusQuery.isError,
seoApiKeyStatusQuery.isSuccess,
shouldCheckSeoApiKeyStatus,
]);
const shouldShowMissingSeoApiKeyModal =
showMissingSeoApiKeyModal && location.pathname !== DATAFORSEO_HELP_PATH;