I watch anime, I play games, and I read books. For a while I had three different apps open to track those things. MyAnimeList for the anime, a spreadsheet for games, and Goodreads for books. None of them talked to each other. If someone asked me what I finished in the last month, I would have to open three tabs and stitch the answer together.
trky is my attempt at collapsing that into one list. It is a single page React app, Vite build, Zustand for state, and localStorage as the whole backend. Seven categories in one table: anime, games, music, books, cartoons, movies, TV shows. The rest of this post is about the two decisions that made the app easy to write and the one decision that will hurt if I ever add real accounts.
The user problem, plainly stated
A fan of one thing is usually a fan of five. The same person who has 200 hours in Baldur's Gate 3 also finished Frieren last season and is halfway through the Stormlight Archive. Existing trackers pick a lane (anime, or games, or books) and force you to context-switch between them. My year in review at the end of December was seven exports, one spreadsheet, and a lot of copy-paste.
The product I wanted was small. Add a title, set a status (planning, in progress, completed, dropped, on hold), rate it out of ten, tick episodes off as I watch, and see the whole thing on one page filtered by category. If that works for one person on one device, it is already better than the tab-juggling.
The data model: one media table, category as a column
The first real decision was whether to model each category separately. Books have authors and page counts, games have platforms and playtime, anime has episode counts, music has albums and tracks. The temptation was to give each category its own schema and its own store, and then teach the UI to render them.
I did not do that. Every item in trky lives in a single media array with the same shape:
{
id: 'anime-3',
category: 'anime',
title: 'Frieren: Beyond Journey\'s End',
subtitle: 'Sousou no Frieren',
cover: '...',
plot: '...',
rating: 9.3,
episodes: 28,
year: 2024,
genres: ['Adventure', 'Drama', 'Fantasy'],
platform: 'Crunchyroll',
isNew: true,
}category is a plain string. episodes is present for anime and TV shows and zero or undefined for a novel. platform is Crunchyroll for anime and Steam for a game. Every field is optional except id, title, and category. Rendering knows to hide the episode counter for a book because episodes is missing, not because a book component was routed to.
The reason I picked this over a discriminated union or per-category collection is boring: 90 percent of the app does not care what category an item is. Search, filter, sort by rating, recommendations, streaks, the year in review, the activity feed. All of these iterate over one list and do not branch on category. The 10 percent that does care (the +1 episode button, the platform label on a card) reads the field it needs and falls back if it is missing. A discriminated union would have made TypeScript happier and added zero real behavior.
An item belongs to one category. There is no "this song is also a soundtrack from that game" edge. If I wanted that, I would add a relatedTo array of ids, not a many-to-many join.
User tracking as a separate map keyed by user, then by media
The catalog of media and what a user has done with a piece of media are two different things. In the store I keep them apart:
userTracking: {
[userId]: {
[mediaId]: {
status, rating, progress, notes,
startedAt, completedAt, updatedAt
}
}
}This nested map is the single most useful shape in the codebase. getUserTrackedMedia(userId) joins it back to the media list. getUserStats(userId) counts statuses. getActivityByDay flattens the timestamps into a heatmap. The streak calculation reads the same map. So does the Spotify-style year in review, which groups by month and by category and picks the top rated.
Because the catalog does not know about users and the tracking does not carry any media detail, I can add a new item to the catalog and every user gets it for free. I can also purge a user's tracking without touching the catalog.
Why Zustand and not Redux, Context, or Jotai
There are seven stores in trky: authStore, trackingStore, collectionStore, chatStore, reviewStore, notificationStore, themeStore. Every one of them is a create((set, get) => ({ ... })) with plain functions on it. Reads are useTrackingStore(state => state.getUserStats(userId)). Writes are useTrackingStore.getState().trackMedia(userId, mediaId, data). Persistence is a saveTracking helper that writes JSON to localStorage on every mutation.
Redux would have been the wrong tool. The app has no async network calls that need thunks or sagas, no time-travel debugging need, and no team of contributors who need a reducer-shaped contract to onboard against. The boilerplate cost is real and the benefit is imaginary for a single-user local app.
Context plus useReducer was the closer runner-up. It is free and it ships with React. The reason I skipped it is subscription granularity. Context re-renders every consumer when any part of the value changes. My tracking store has hundreds of items and dozens of derived selectors. With Zustand I subscribe to state => state.userTracking[userId]?.[mediaId]?.progress on the card that shows one item's progress, and only that card re-renders when I tick +1. Context would have needed splitting into a dozen small contexts to get the same result, and at that point I have re-implemented Zustand badly.
Jotai was the one I actually considered. Atomic state, fine-grained subscriptions, small API. I did not pick it for one reason: the derived selectors in trackingStore are chunky. getRecommendations weighs genres, categories, ratings, and recency across the entire library. Writing that as a graph of atoms is more work than writing it as one function inside a Zustand store. Atoms are great when the derivations are small and composable. Mine are neither.
Notes on making the store double as the backend
Because there is no server, every store method is also the API. trackMedia writes to state and to localStorage in the same call. Notifications are triggered from inside the store when status flips to completed. The +1 episode button auto-completes the item when progress hits episodes and fires a completion notification from the reducer. This works only because there is exactly one caller (the browser tab) and exactly one writer.
The moment there are two tabs, the last write wins. Open trky in two windows, add an item in one, refresh the other, and only the fresher tab's state survives. A storage event listener would fix that for the same-user case. I have not written it because I have not been bitten by it.
What breaks when it becomes multi-user
Every mutation in the store takes userId as the first argument. That felt clever at the time. In practice it means the client trusts itself to identify who is writing. Fine for a device I own. Not fine the moment two people share a URL.
If I ever added real accounts, the changes cascade:
The userTracking map has to move server-side, because localStorage is per-device and per-browser. Each mutation becomes a request, which means every store method that currently returns synchronously now returns a promise. trackMedia becomes await trackMedia, which means the components that call it need loading states, error states, and optimistic UI, none of which exist today.
The auth store has to stop pretending. It currently hashes a password with SHA-256 and a hardcoded salt string and writes it to localStorage. That is a rate-of-progress stopper, not security. Real accounts need a backend with a session cookie, a password reset flow, and something like Supabase or Clerk to handle it.
The recommendation function would move too. Right now it scores every item against every tracked item on every render, which is fine for 200 items in memory. At a real scale that becomes a server job with a materialized table, and the client just reads the top-N.
The one piece that survives cleanly is the media shape. Category as a string, optional fields, no per-category schema. That decision was cheap to make and expensive to change, and I got it right by accident on the first try.
For now trky lives on GitHub Pages, ships with sample data and three live API integrations (AniList, Jikan, Open Library) for search and trending feeds, and is fast enough that the whole app renders on a cold load in under half a second. That is the version I actually use.