Tutorial: Build a "Top Albums" stat card
This tutorial walks you through adding a complete new feature to Unwrap. You'll touch every layer of the architecture, write a test, and end up with a working, customisable stat card.
If you've read the Overview and Architecture pages, this tutorial puts those concepts into practice.
What you'll learn
- How to add a new dashboard category end-to-end
- The three-layer pattern (Component → Hook → Service) in real use
- How to define an API contract before the backend exists
- How to write an MSW handler and a fixture
- How to write an integration test using shared test infrastructure
Before you start
You need:
- The dev server running (
npm run dev) - A code editor with TypeScript support
- About 30–45 minutes.
We'll build the feature in this order:
- Define the API contract (types)
- Add a service function
- Add a fixture and MSW handler so the service has something to return
- Add a TanStack Query hook
- Build the component
- Register the new category in the dashboard store
- Render it from the Dashboard page
- Write a test
Each step builds directly on the previous. If something doesn't work at the end of a step, fix it before moving on.
Step 1: Define the API contract
Every layer below this step depends on the response shape, so we define it first.
Open src/types/api.ts and add:
export interface Album {
rank: number;
albumId: string;
albumName: string;
artistName: string;
playCount: number;
albumArtUrl: string;
}
export interface TopAlbumsResponse {
pagination: Pagination;
topAlbums: Album[];
}Then extend the DashboardCategory union (in the same file) so the dashboard can recognise the new category:
export type DashboardCategory =
| 'top-tracks'
| 'top-artists'
| 'top-albums' // ← new
| 'top-genres'
| 'top-skips'
| 'top-replays'
| 'peak-hour'
| 'most-active-day'
| 'total-minutes';NOTE
By defining the response shape before any code calls the API, every later layer is type-checked against the same contract. If you change the contract, TypeScript will tell you exactly which files break.
Step 2: Add a service function
Services do one job: fetch and return typed data. Open src/services/api.ts and add:
/**
* Fetches top albums for the given range
* Mirrors the shape of fetchTopTracks/fetchTopArtists for consistency
*/
export async function fetchTopAlbums(
timeRange?: TimeRange,
limit = 10,
offset = 0
): Promise<TopAlbumsResponse> {
return apiFetch('/api/top-albums', buildParams({ timeRange, limit, offset }));
}Three things to notice:
- No
useQuery, no React. Pure async function. - The default
limitandoffsetmatchfetchTopTracksso callers can ignore them. - The endpoint path follows the existing convention
/api/top-{thing}. Consistency makes the surface easy to remember.
If you save and run npm run dev, nothing visible has changed yet, but TypeScript should be happy. If it isn't, fix the type errors before continuing.
Step 3: Add a fixture and MSW handler
The backend doesn't have a /api/top-albums endpoint yet. MSW lets us mock it.
Open src/mocks/fixtures.ts and add fixture data. Use the same shape as the response type:
export const topAlbumsFixture: TopAlbumsResponse = {
pagination: {
limit: 10,
offset: 0,
total: 4,
hasMore: false,
nextOffset: null,
previousOffset: null,
},
topAlbums: [
{
rank: 1,
albumId: 'a1',
albumName: 'To Pimp a Butterfly',
artistName: 'Kendrick Lamar',
playCount: 142,
albumArtUrl: '',
},
{
rank: 2,
albumId: 'a2',
albumName: 'Currents',
artistName: 'Tame Impala',
playCount: 118,
albumArtUrl: '',
},
{
rank: 3,
albumId: 'a3',
albumName: 'In Rainbows',
artistName: 'Radiohead',
playCount: 97,
albumArtUrl: '',
},
{
rank: 4,
albumId: 'a4',
albumName: 'Channel Orange',
artistName: 'Frank Ocean',
playCount: 84,
albumArtUrl: '',
},
],
};Then in src/mocks/handlers.ts, add a handler that returns it:
import { topAlbumsFixture } from './fixtures';
// inside the exported handlers array:
http.get('/api/top-albums', () => HttpResponse.json(topAlbumsFixture)),Refresh the dev server. Open your browser DevTools → Network tab. The request to /api/top-albums won't actually fire yet (no component calls it), but the handler is registered and waiting.
This is an honest contract
The fixture matches TopAlbumsResponse exactly because TypeScript enforces it. If you change the type, the fixture breaks at compile time. This is what makes mock-driven development sustainable as the mocks can't drift from the contract.
Step 4: Add a TanStack Query hook
Hooks turn services into reactive data. Open src/hooks/useUnwrapData.ts (or wherever your other category hooks live) and add:
export function useTopAlbums(timeRange: TimeRange) {
return useQuery({
queryKey: ['top-albums', timeRange],
queryFn: () => fetchTopAlbums(timeRange),
});
}Three points to consider:
- The query key includes
timeRange. Switching from "All Time" to "Past Week" creates a separate cache entry. Switch back, and the cached "All Time" data is returned instantly. - The hook returns the standard TanStack Query shape
{ data, isLoading, isError, ... }. The component will consume that, not the raw service result. - No transformation here. The hook is a thin wrapper. Transforming or formatting data is the component's job (or a util).
Step 5: Build the component
This is the visual layer. Create src/components/AlbumList/AlbumList.tsx:
import { useTopAlbums } from '@/hooks/useUnwrapData';
import type { TimeRange } from '@/types/api';
import Skeleton from '@/components/Skeleton/Skeleton';
import styles from './AlbumList.module.css';
interface Props {
timeRange: TimeRange;
}
export default function AlbumList({ timeRange }: Props) {
const { data, isLoading, isError } = useTopAlbums(timeRange);
if (isLoading) {
return (
<div className={styles.list}>
<Skeleton height="200px" />
</div>
);
}
if (isError || !data) {
return <p className={styles.error}>Couldn't load top albums.</p>;
}
return (
<section className={styles.list}>
<h3 className={styles.heading}>Top Albums</h3>
<ol className={styles.items}>
{data.topAlbums.map((album) => (
<li key={album.albumId} className={styles.item}>
<span className={styles.rank}>{album.rank}</span>
<div className={styles.meta}>
<p className={styles.name}>{album.albumName}</p>
<p className={styles.sub}>
{album.artistName} · {album.playCount} plays
</p>
</div>
</li>
))}
</ol>
</section>
);
}And the matching AlbumList.module.css:
.list {
background-color: var(--color-surface);
border-radius: var(--radius);
padding: 16px;
}
.heading {
font-size: 1rem;
margin-bottom: 12px;
}
.items {
list-style: none;
padding: 0;
margin: 0;
}
.item {
display: flex;
gap: 12px;
padding: 8px 0;
}
.rank {
font-weight: 700;
color: var(--color-text-secondary);
min-width: 24px;
}
.name {
font-weight: 600;
}
.sub {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.error {
color: var(--color-error);
}Why this component is small
It does one thing: render top albums. It doesn't fetch (the hook does), doesn't cache (TanStack Query does), and doesn't decide whether it should be visible (the dashboard does). That's SRP in practice.
Step 6: Register the category
The dashboard reads category metadata from a single map in src/stores/dashboardStore.ts. Add the new entry:
export const CATEGORY_LABELS: Record<DashboardCategory, string> = {
'top-tracks': 'Top Tracks',
'top-artists': 'Top Artists',
'top-albums': 'Top Albums', // ← new
'top-genres': 'Top Genres',
'top-skips': 'Top Skips',
'top-replays': 'Top Replays',
'peak-hour': 'Peak Listening Hour',
'most-active-day': 'Most Active Day',
'total-minutes': 'Total Minutes Listened',
};This is the only change needed in the store. ALL_CATEGORIES is derived from this object's keys, so the picker, persistence, and toggling logic all pick it up automatically. You can also add it to DEFAULT_CATEGORIES if you want it visible out of the box.
Step 7: Render it from the Dashboard
Open src/pages/Dashboard.tsx. Find the CategoryCard switch statement and add a new case:
import AlbumList from '@/components/AlbumList/AlbumList';
// inside CategoryCard:
case 'top-albums':
return <AlbumList timeRange={timeRange} />;Save. Open the dashboard in your browser. Click Customise, tick Top Albums, and the new card should appear.
🎉 You've added a fully working stat card across every architectural layer.
Step 8: Write a test
A feature without a test is half-finished. Create src/__tests__/components/AlbumList/AlbumList.test.tsx:
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';
import { renderWithProviders } from '@/test/renderWithProviders';
import { screen, waitFor } from '@testing-library/react';
import AlbumList from '@/components/AlbumList/AlbumList';
describe('AlbumList', () => {
it('renders top albums after loading', async () => {
renderWithProviders(<AlbumList timeRange="all" />);
expect(await screen.findByText('To Pimp a Butterfly')).toBeInTheDocument();
expect(screen.getByText(/Kendrick Lamar/)).toBeInTheDocument();
expect(screen.getByText(/142 plays/)).toBeInTheDocument();
});
it('shows an error message when the request fails', async () => {
server.use(
http.get('/api/top-albums', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 })
)
);
renderWithProviders(<AlbumList timeRange="all" />);
await waitFor(() => {
expect(screen.getByText(/couldn't load top albums/i)).toBeInTheDocument();
});
});
});Run the test:
npm run test:run -- AlbumListBoth tests should pass. The first uses the default MSW handler from handlers.ts. The second overrides it for one test using server.use(), see the How to write a test guide for more on this pattern.
Tutorial checklist
You built a feature that:
- Has a fully typed API contract (
Album,TopAlbumsResponse) - Has a pure HTTP service (
fetchTopAlbums) - Has a mock that lets the frontend run without a backend
- Has a cached, retrying data hook (
useTopAlbums) - Has a presentational component (
AlbumList) - Is integrated into the dashboard's customisation system
- Has tests covering both the success and error paths