Skip to content

Loading API Data: GitHub Contributors

This demo shows the top 30 contributors of the facebook/react repository.

It fetches contributor data from the GitHub API, converts it into a matrix, and renders avatars, profile links, and contribution bars with custom policies. Every cell also shows its own address in the top-right corner via renderCallback.

Loading contributors data...

📄 View Source Code

Converting an API Response to a Matrix with oa2aa

Section titled “Converting an API Response to a Matrix with oa2aa”

oa2aa (Object Array to Array Array) converts an array of objects into the 2D array format that GridSheet expects.

const fields = ['avatar_url', 'login', 'contributions'];
const response = await request('GET /repos/{owner}/{repo}/contributors', { ... });
setData(oa2aa(response.data as { [s: string]: any }[], fields));

List the keys you want to extract in fields; each object becomes a row, and the order of fields determines the column order (here A = avatar, B = login, C = contributions).

Loading Dynamic Data with buildInitialCellsFromOrigin

Section titled “Loading Dynamic Data with buildInitialCellsFromOrigin”

After fetching, pass the matrix to buildInitialCellsFromOrigin to populate the grid starting from A1. Unlike the matrices key in buildInitialCells, the origin is always fixed at A1.

<GridSheet
book={book}
sheetName="contributors"
initialCells={buildInitialCellsFromOrigin({
matrix: data, // the state value from useState
cells: { ... }, // column policies, widths and styles go here
})}
/>

Although initialCells is recomputed whenever data changes, GridSheet only reads it on first initialization. That is why the grid is only rendered once loading has finished (see below).

Each column gets a policy whose mixin overrides the renderer for its value type. renderString turns the avatar URL into an image and the login into a profile link; renderNumber draws a log-scaled bar behind the contribution count.

const ContributionsBarMixin: PolicyMixinType = {
renderNumber({ value }: RenderProps<number>) {
const LOG_MAX = Math.log(15000 + 1);
const ratio = Math.log((value || 0) + 1) / LOG_MAX;
const pct = Math.min(ratio * 100, 100);
const formatted = (value || 0).toLocaleString();
return (
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}>
<div
style={{
position: 'absolute',
left: 0,
top: 2,
bottom: 2,
width: `${pct}%`,
backgroundColor: 'rgba(46, 204, 113, 0.3)',
borderRadius: '2px',
}}
/>
<span style={{ position: 'relative', marginLeft: 'auto', paddingRight: 4 }}>{formatted}</span>
</div>
);
},
};

Post-processing Rendered Output with renderCallback

Section titled “Post-processing Rendered Output with renderCallback”

renderCallback on PolicyMixinType runs after the type-specific render method (renderString, renderNumber, etc.). It receives the already-rendered output plus RenderProps, which makes it ideal for decorations that don’t depend on the value type — such as an address badge. This is the recommended replacement for the showAddress option removed in v3.

const AddressOverlayMixin: PolicyMixinType = {
renderCallback(rendered: any, { point }: RenderProps) {
const address = p2a(point);
return (
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
style={{
position: 'absolute',
top: 2,
right: 4,
fontSize: '9px',
color: 'var(--gs-muted-fg)',
fontFamily: 'monospace',
}}
>
{address}
</span>
{rendered}
</div>
);
},
};

p2a(point) converts the { y, x } coordinate to an address string like "A1". Using the var(--gs-muted-fg) CSS variable keeps the badge readable in both light and dark mode.

A single Policy can combine multiple mixins. Later mixins override earlier ones for the same key, so the renderer, a header-label override, and the address overlay can be stacked:

const book = useSpellbook({
policies: {
avatar: new Policy({
mixins: [
ImagePolicyMixin, // renders the image via renderString
{ renderColHeaderLabel: () => 'Avatar' }, // overrides the column header label
AddressOverlayMixin, // adds the address badge via renderCallback
],
}),
user: new Policy({ mixins: [UserLinkMixin, { renderColHeaderLabel: () => 'User' }, AddressOverlayMixin] }),
contributions: new Policy({
mixins: [ContributionsBarMixin, { renderColHeaderLabel: () => 'Contributions' }, AddressOverlayMixin],
}),
},
});

An inline object with renderColHeaderLabel is all you need to customize a column’s header label.

When the number of columns is fixed by the data source, set equal minCols and maxCols so users can’t add or remove columns. mode: inheritMode (from the docs’ useStarlightMode hook) makes the grid follow the site’s light/dark theme.

options={{
matrixAlignment: 'both',
mode: inheritMode,
sheetHeight: 500,
limits: { minCols: 3, maxCols: 3 },
}}

Async Fetching with Loading and Error States

Section titled “Async Fetching with Loading and Error States”

The fetch lives in a useCallback so it can run on mount and again from a Retry button. On failure, the error (including GitHub’s unauthenticated rate limit of 60 requests/hour) is stored in state and shown instead of an empty grid.

const load = React.useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await request('GET /repos/{owner}/{repo}/contributors', { ... });
setData(oa2aa(response.data as { [s: string]: any }[], fields));
} catch (error) {
console.error('Failed to fetch data:', error);
setError(describeError(error));
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
load();
}, [load]);

The component then renders a spinner while loading, an error panel with a Retry button when error is set (or no rows came back), and the GridSheet otherwise. Calling setLoading(false) in finally ensures the spinner disappears even when the request fails.