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.

Example stat cards

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:

Figma designs

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 divs 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.

Loading
-
Loaded
12345
Error
Error

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...

Github Followers
-

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 cache
  • max-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.