Overview
Film Fanatic uses a hybrid state management approach:- Zustand - Client-side state (watchlist, episode progress)
- TanStack Query - Server state & caching (TMDB data, Convex queries)
- Convex Optimistic Updates - Instant UI feedback
Client State with Zustand
Watchlist Store
Persists watchlist data to localStorage for logged-out users:import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
interface WatchlistStore {
mediaState: WatchlistItem[];
setWatchlistMembershipLocal: (
id: string,
type: MediaType,
inWatchlist: boolean,
metadata?: MediaMetadata,
) => void;
setProgressStatusLocal: (
id: string,
type: MediaType,
progressStatus: ProgressStatus,
progress?: number,
metadata?: MediaMetadata,
) => void;
setReactionLocal: (
id: string,
type: MediaType,
reaction: ReactionStatus | null,
metadata?: MediaMetadata,
) => void;
}
export const useWatchlistStore = create<WatchlistStore>()(
persist(
(set) => ({
mediaState: [],
setWatchlistMembershipLocal: (id, type, inWatchlist, metadata) =>
set((state) => {
const existingIndex = state.mediaState.findIndex((item) =>
isSameItem(item, id, type),
);
if (existingIndex === -1) {
if (!inWatchlist) return state;
const next = buildFallbackItem(id, type, metadata);
next.inWatchlist = true;
next.progressStatus = "want-to-watch";
return { mediaState: [next, ...state.mediaState] };
}
const items = [...state.mediaState];
items[existingIndex] = {
...items[existingIndex],
inWatchlist,
title: metadata?.title ?? items[existingIndex].title,
updated_at: Date.now(),
};
return { mediaState: items };
}),
}),
{
name: "watchlist-storage",
storage: createJSONStorage(() =>
typeof window !== "undefined" ? window.localStorage : memoryStorage,
),
},
),
);
Episode Progress Store
Tracks watched episodes for TV shows:import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
interface LocalProgressStore {
watchedEpisodes: Record<string, boolean>; // key: `${tmdbId}:${season}:${episode}`
markEpisodeWatched: (
tmdbId: number,
season: number,
episode: number,
isWatched: boolean,
) => void;
markSeasonWatched: (
tmdbId: number,
season: number,
episodes: number[],
isWatched: boolean,
) => void;
clearShowProgress: (tmdbId: number) => void;
}
export const useLocalProgressStore = create<LocalProgressStore>()(
persist(
(set) => ({
watchedEpisodes: {},
markEpisodeWatched: (tmdbId, season, episode, isWatched) =>
set((state) => {
const key = `${tmdbId}:${season}:${episode}`;
const newEpisodes = { ...state.watchedEpisodes };
if (isWatched) newEpisodes[key] = true;
else delete newEpisodes[key];
return { watchedEpisodes: newEpisodes };
}),
markSeasonWatched: (tmdbId, season, episodes, isWatched) =>
set((state) => {
const newEpisodes = { ...state.watchedEpisodes };
for (const episode of episodes) {
const key = `${tmdbId}:${season}:${episode}`;
if (isWatched) newEpisodes[key] = true;
else delete newEpisodes[key];
}
return { watchedEpisodes: newEpisodes };
}),
clearShowProgress: (tmdbId) =>
set((state) => {
const newEpisodes = { ...state.watchedEpisodes };
const prefix = `${tmdbId}:`;
for (const key of Object.keys(newEpisodes)) {
if (key.startsWith(prefix)) delete newEpisodes[key];
}
return { watchedEpisodes: newEpisodes };
}),
}),
{
name: "local-progress-store",
storage: createJSONStorage(() =>
typeof window !== "undefined" ? window.localStorage : memoryStorage,
),
},
),
);
Server State with TanStack Query
Query Client Setup
Configured with sensible defaults for caching:import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function getContext() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 24 * 60 * 60 * 1000, // 24 hours
gcTime: 60 * 60 * 1000, // 1 hour
retry: 0,
},
},
});
return { queryClient };
}
export function Provider({
children,
queryClient,
}: {
children: React.ReactNode;
queryClient: QueryClient;
}) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
Optimistic Updates with Convex
Toggle Watchlist with Instant Feedback
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
export function useToggleWatchlistItem() {
const { isSignedIn } = useUser();
const setWatchlistMembership = useMutation(
api.watchlist.setWatchlistMembership,
).withOptimisticUpdate((localStore, args) => {
const current = localStore.getQuery(api.watchlist.getWatchlist, {}) ?? [];
if (args.inWatchlist) {
const existing = current.find(
(i) => i.tmdbId === args.tmdbId && i.mediaType === args.mediaType,
);
if (existing) {
// Update existing item
localStore.setQuery(
api.watchlist.getWatchlist,
{},
current.map((i) =>
i === existing
? { ...i, inWatchlist: true, updatedAt: Date.now() }
: i,
),
);
} else {
// Add new item
localStore.setQuery(api.watchlist.getWatchlist, {}, [
...current,
{
tmdbId: args.tmdbId,
mediaType: args.mediaType,
title: args.title,
image: args.image,
rating: args.rating,
inWatchlist: true,
updatedAt: Date.now(),
},
]);
}
} else {
// Remove from watchlist
localStore.setQuery(
api.watchlist.getWatchlist,
{},
current.map((i) =>
i.tmdbId === args.tmdbId && i.mediaType === args.mediaType
? { ...i, inWatchlist: false }
: i,
),
);
}
});
return useCallback(async (item) => {
if (isSignedIn) {
await setWatchlistMembership({
tmdbId: Number(item.id),
mediaType: item.media_type,
inWatchlist: !isInWatchlist,
title: item.title,
image: item.image,
rating: item.rating,
});
} else {
// Fallback to local storage for logged-out users
setLocalWatchlistMembership(item.id, item.media_type, !isInWatchlist, {
title: item.title,
image: item.image,
rating: item.rating,
});
}
}, [isSignedIn, setWatchlistMembership]);
}
Hybrid State Pattern
For logged-out users, state is stored in Zustand + localStorage. For logged-in users, state syncs to Convex with optimistic updates.export function useWatchlist() {
const { isSignedIn } = useUser();
const convexWatchlistData = useQuery(
api.watchlist.getWatchlist,
isSignedIn ? {} : "skip",
);
const localMediaState = useWatchlistStore((state) => state.mediaState);
const watchlist: WatchlistItem[] = useMemo(() => {
if (isSignedIn) {
// Use Convex data for logged-in users
if (!convexWatchlistData) return [];
return convexWatchlistData
.map((item) => mapConvexItemToWatchlistItem(item))
.filter((item) => item.inWatchlist)
.sort((a, b) => b.updated_at - a.updated_at);
}
// Use local Zustand store for logged-out users
return [...localMediaState]
.filter((item) => item.inWatchlist)
.sort((a, b) => b.updated_at - a.updated_at);
}, [isSignedIn, convexWatchlistData, localMediaState]);
return { watchlist, loading };
}
Key Benefits
- Instant feedback - Optimistic updates make UI feel responsive
- Offline support - Zustand + localStorage for logged-out users
- Type safety - Auto-generated Convex types
- Efficient caching - TanStack Query reduces API calls
- SSR compatible - Server state hydration with TanStack Router
