Building a StatCard component
3rd August 2021
The personal websites of software engineers often have some display of metrics: Github stars, Twitter followers and NPM downloads.
The examples above are from Lee Robinson and Jared Palmer. This post is how I've attempted to build something similar for my own blog.
My motivation for building this is to have something that can be used as an example for future posts.
Requirements
I started off by listing some requirements using the MoSCoW method: must have, should have, could have and won't have.
Must: Fetch some metrics from an API and present those to the user.
Should: Provide reasonably up-to-date results while allowing for a sensible amount of caching. The component should be resuable since metrics could come from other sources in the future e.g. number of blog posts which would be found by counting the number of .mdx
files at build-time.
Could: Format large numbers e.g. 123000 as 1.2M. This isn't very important since the largest metric I have currently is 2 digits 🥲
Won't: Handle values with units e.g. currency.
Design
I created some quick mockups in Figma. The goal here was to think about different states and to spot potential edge cases. Here's what I came up with:
There are three states: loading, loaded and error. The component doesn't have much room to provide useful feedback to the user when something goes wrong. I considered displaying a toast (with react-hot-toast for example) but between my error tracking and edge caching I don't think this will be an issue.
Implementation
The implementation consists of three parts: a card component that displays a stat, something that fetches stats from a source and a way of joining the two together into the final result.
StatsCard
component
type Props = {
id: string;
title: string;
value?: number;
isLoading?: boolean;
hasError?: boolean;
};
export default function StatCard({
title,
value,
isLoading,
hasError,
id,
}: Props) {
let content: React.ReactElement = <>{value}</>;
if (isLoading) {
content = <>-</>;
}
if (hasError) {
content = (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-10 w-10"
viewBox="0 0 20 20"
fill="currentColor"
role="img"
>
<title>Error</title>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
}
return (
<div className="flex flex-col flex-1 border-2 border-gray-800 text-gray-800 rounded-lg text-center">
<div className="bg-gray-800 text-white px-4 py-2 text-lg" id={id}>
{title}
</div>
<div
className="flex justify-center items-center text-4xl font-bold p-4"
aria-labelledby={id}
>
{content}
</div>
</div>
);
}
My component accepts a value
prop which can be any number. This meets the requirement of being reusable because it doesn't care where the data is from.
Since the component can be used in many different contexts, I've chosen div
s instead of more semantic elements such as dt
and dd
. It uses the aria-labelledby
attribute to help assistive technologies understand what the numbers refer to.
The exclamation icon is from Heroicons.
Statistics API route
I created this GET /api/stats/<source>
API route that is responsible for fetching my metrics. The example is using the Github API to fetch the number of people who follow me.
import { githubUsername } from "@/lib/accounts";
import { withSentry } from "@sentry/nextjs";
import { NextApiRequest, NextApiResponse } from "next";
import nc from "next-connect";
const handler = nc<NextApiRequest, NextApiResponse>().get(async (req, res) => {
const resp = await fetch(`https://api.github.com/users/${githubUsername}`);
const body = await resp.json();
res.setHeader("Cache-Control", "public, max-age=3600");
return res.json({
followers: body.followers,
});
});
export default withSentry(handler);
Next connect lets me target just the GET
requests and remove a few lines of boilerplate. I'm using Sentry to track errors which means I need to wrap the handler in withSentry
.
Client-side data fetching
To consume the newly created API endpoint, I've written a custom hook called useGithubStats
. I've chosen to use SWR to handle the request state because it has a simple API and is easy to install.
import useSWR from "swr";
export default function useGithubStats() {
const { error, data } = useSWR("/api/stats/github", async (url: string) => {
const resp = await fetch(url);
return resp.json();
});
return {
isLoading: !error && !data,
hasError: !!error,
data,
};
}
The data from useGithubStats
gets passed as the input into StatCard
to form a GithubFollowersCard
component.
import useGithubStats from "@/hooks/useGithubStats";
import React from "react";
import StatCard from "./StatCard";
export default function GithubFollowersCard() {
const { data, isLoading, hasError } = useGithubStats();
return (
<StatCard
title="Github Followers"
value={data?.followers}
isLoading={isLoading}
hasError={hasError}
/>
);
}
Now the moment you've all been waiting for. My Github follower count is...
Wowwww! I'll check back in a year or so 😅
Caching
Caching is really important because I don't want to be making requests to the Github API every time I render a page. I want to be able to reuse the data from the previous request.
The Cache-Control
header returned by my API route provides instructions to the cache:
res.setHeader("Cache-Control", "public, max-age=3600");
Let's break down this directive:
public
means that the response is safe to be stored in any cachemax-age=3600
tells the cache that the value is valid for the next hour (3600 seconds)
Vercel's edge network will see this header and store a cached response for an hour. This means that subsequent requests get a fast response and I don't worry about being rate limited by Github. More on Vercel's caching.
Conclusion
To recap, I've covered building an accessible component that can be used in many different contexts. Built an API route that fetches statistics from Github and makes use caching to provide a better user experience. Finally, combined everything into a working example using SWR to handle the request state.
If you enjoyed this post then please share it with your friends, colleagues and grandma. I'd love to hear any feedback on how the writing or code could be improved.