with one click
tanstack-query-patterns
// Guide for using TanStack Query patterns in the Nora project. Use when implementing data fetching, creating query modules, or consuming IPC data in React components.
// Guide for using TanStack Query patterns in the Nora project. Use when implementing data fetching, creating query modules, or consuming IPC data in React components.
| name | tanstack-query-patterns |
| description | Guide for using TanStack Query patterns in the Nora project. Use when implementing data fetching, creating query modules, or consuming IPC data in React components. |
| applyTo | src/renderer/src/**/*.ts* |
Context: Use this skill when adding new data fetching features, creating query modules, or consuming data from IPC in React components.
src/renderer/src/queries/ using @lukemorales/query-key-factorywindow.api.* calls to the Electron main processuseSuspenseQuery for declarative data loading in routessrc/renderer/src/queries/
āāā songs.ts # songQuery: all(), allSongInfo(), singleSongInfo(), favorites(), history(), queue(), similarTracks()
āāā aritsts.ts # artistQuery: all(), single(), fetchOnlineInfo()
āāā albums.ts # albumQuery: all(), single()
āāā playlists.ts # playlistQuery: all(), single(), songArtworks()
āāā genres.ts # genreQuery: all(), single()
āāā home.ts # homeQuery: recentlyPlayedSongs(), recentSongArtists(), mostLovedSongs()
āāā listens.ts # listenQuery: single()
āāā search.ts # searchQuery: recentResults(), query()
āāā lyrics.ts # lyricsQuery (if needed)
āāā settings.ts # settingsQuery (with mutations)
āāā queue.ts # queueQuery
āāā userPreferences.ts # userPreferencesQuery (with mutations)
āāā other.ts # otherQuery: databaseMetrics()
For straightforward data fetches (e.g., all songs, artist info), create a single queryKey without parameters:
// src/renderer/src/queries/home.ts
import { createQueryKeys } from '@lukemorales/query-key-factory';
export const homeQuery = createQueryKeys('home', {
recentlyPlayedSongs: {
queryKey: null, // No dynamic parameters
queryFn: async (): Promise<SongData[]> => {
try {
const { data: playlists } = await window.api.playlistsData.getPlaylistData([
SpecialPlaylists.History
]);
const historyPlaylist = playlists[0];
if (!historyPlaylist || historyPlaylist.songs.length === 0) return [];
const songs = await window.api.audioLibraryControls.getSongInfo(
historyPlaylist.songs,
undefined,
undefined,
35,
true
);
return Array.isArray(songs) ? songs : [];
} catch (error) {
console.error(error);
return [];
}
}
}
});
For queries with dynamic parameters (filters, sorting, pagination), accept a data object and return both queryKey and queryFn:
// src/renderer/src/queries/songs.ts
export const songQuery = createQueryKeys('songs', {
all: (data: {
sortType: SongSortTypes;
filterType?: SongFilterTypes;
start?: number;
end?: number;
}) => {
const { sortType = 'addedOrder', filterType = 'notSelected', start = 0, end = 0 } = data;
return {
queryKey: [
`sortType=${sortType}`,
`filterType=${filterType}`,
`start=${start}`,
`end=${end}`,
`limit=${end - start}`
],
queryFn: () =>
window.api.audioLibraryControls.getAllSongs(sortType, filterType, {
start,
end
})
};
}
});
ā DO:
sortType=${sortType}songIds=${[...songIds].sort().join(',')}start, end, limit, sortType, filterTypeā DON'T:
Ensure data is available before component renders:
// src/renderer/src/routes/main-player/home/index.tsx
export const Route = createFileRoute('/main-player/home/')({
component: HomePage,
loader: async () => {
await queryClient.ensureQueryData(
songQuery.all({ sortType: 'dateAddedDescending', start: 0, end: 30 })
);
await queryClient.ensureQueryData(homeQuery.recentlyPlayedSongs);
await queryClient.ensureQueryData(homeQuery.recentSongArtists);
await queryClient.ensureQueryData(homeQuery.mostLovedSongs);
}
});
Use useSuspenseQuery for components inside route that already has loader:
function HomePage() {
const { data: latestSongs } = useSuspenseQuery(
songQuery.all({ sortType: 'dateAddedDescending', start: 0, end: 30 })
);
const { data: recentlyPlayedSongs } = useSuspenseQuery(homeQuery.recentlyPlayedSongs);
// Component renders safely with data
return (
<RecentlyPlayedSongs
songs={recentlyPlayedSongs.slice(0, 10)}
noOfVisibleSongs={10}
/>
);
}
For queries that should only run based on conditions, use enabled option:
const { data: artistInfo } = useSuspenseQuery({
...artistQuery.fetchOnlineInfo({ artistId: selectedArtistId }),
enabled: !!selectedArtistId // Only fetch if artistId exists
});
Store both queries and mutations in the same module:
// src/renderer/src/queries/settings.ts
export const settingsMutation = {
toggleAutoLaunch: () => ({
mutationFn: (autoLaunchState: boolean) =>
window.api.settingsHelpers.toggleAutoLaunch(autoLaunchState),
onSuccess: () => {
queryClient.invalidateQueries(settingsQuery);
}
})
};
// Usage in component
const { mutate: toggleAutoLaunch } = useMutation(settingsMutation.toggleAutoLaunch());
When data changes via mutations, invalidate affected queries:
// After toggling song favorite status
queryClient.invalidateQueries({
queryKey: songQuery.all.queryKey // Invalidate all song queries
});
// After adding to playlist
queryClient.invalidateQueries({
queryKey: playlistQuery.all.queryKey // Invalidate playlist list
});
All query functions should gracefully handle errors and return safe defaults:
// ā
GOOD: Safe error boundary
export const homeQuery = createQueryKeys('home', {
recentlyPlayedSongs: {
queryKey: null,
queryFn: async (): Promise<SongData[]> => {
try {
const { data: playlists } = await window.api.playlistsData.getPlaylistData([
SpecialPlaylists.History
]);
// ... process ...
return Array.isArray(songs) ? songs : []; // Fallback to empty array
} catch (error) {
console.error(error);
return []; // Return safe default
}
}
}
});
// ā AVOID: Throwing errors without fallback
queryFn: async () => {
const { data } = await window.api.playlistsData.getPlaylistData([...]);
return data; // Will throw if API fails, breaking component
}
songs, artists, home, playlistsall, single, favorites, recentlyPlayedSongssortType=${sortType}, songIds=${[...].sort().join(',')}Example:
homeQuery.recentlyPlayedSongs
> Module: 'home'
> Key: null (no params)
> Cache: ['home', 'recentlyPlayedSongs']
songQuery.all({ sortType: 'aToZ', start: 0, end: 30 })
> Module: 'songs'
> Key: 'all', 'sortType=aToZ', 'start=0', 'end=30', 'limit=30'
> Cache: ['songs', 'all', 'sortType=aToZ', 'start=0', 'end=30', 'limit=30']
When one query depends on another:
const fetchRecentSongArtists = async (): Promise<Artist[]> => {
try {
// 1. Ensure recently played songs are cached
const recentlyPlayedSongs = await queryClient.ensureQueryData(homeQuery.recentlyPlayedSongs);
if (recentlyPlayedSongs.length === 0) return [];
// 2. Extract artist IDs from songs
const artistIds = [
...new Set(
recentlyPlayedSongs
.map((song) => song.artists?.map((artist) => artist.artistId) ?? [])
.flat()
)
];
if (artistIds.length === 0) return [];
// 3. Fetch artist data via IPC
const { data: artists } = await window.api.artistsData.getArtistData(
artistIds,
undefined,
undefined,
0,
35
);
return artists;
} catch (error) {
console.error(error);
return [];
}
};
When query accepts an array parameter (e.g., song IDs), sort before building cache key:
allSongInfo: (data: { songIds: number[] }) => {
const { songIds } = data;
return {
queryKey: [
// Sort to ensure cache key stability (always same regardless of input order)
`songIds=${[...songIds].sort().join(',')}`
],
queryFn: () => window.api.audioLibraryControls.getSongInfo(songIds)
};
};
src/renderer/src/queries/: Never define queries inline in componentsInstall @tanstack/react-query-devtools to inspect cache:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
export default App() {
return (
<>
{/* Your app */}
<ReactQueryDevtools initialIsOpen={false} />
</>
);
}
// Log current cache state
console.log(queryClient.getQueryData(songQuery.all({ sortType: 'aToZ' }).queryKey));
// Manually invalidate and refetch
queryClient.invalidateQueries({
queryKey: ['songs']
});
src/renderer/src/queries/*.tssrc/preload/index.ts (defines window.api interface)src/renderer/src/routes/main-player/home/index.tsxsrc/renderer/src/index.tsx (queryClient initialization)