What makes a good dashboard
- Front-end
- React
I've worked on a bunch of dashboard applications until now, and I found some aspects that kind of distinguish them from other applications:
- Your user probably uses this app a lot, therefore efficiency and good UX matter more here, a small UX annoyance in an application you use casually will be 10x more annoying if use this app daily.
- Dashboards are usually desktop-first in design, thus keyboard support should be first-class citizen in your app.
- Data is dynamic, and changes very frequently, so you should be very careful with caching.
I have a few tips that address the points above which improves the UX of a dashboard application, and any web app actually, but I think they are more relevant in a dashboard context, where UX is very crucial.
Table of Contents
State should be in the URL
I started with this one, because of how much I believe it's important and how often I see that it's not implemented.
Because of the way the web is today, it's not enforced to make your URL hold any state, you can make a whole application with only one route! (please don't), but even if it's easier/less work not to implement it, it shouldn't be skipped over, as it's quite frustrating to be missing in any non-trivial application. Consider a simple search bar:
The user is expecting a new change in the data so they keep refreshing the page, now if your url doesn't look like this https://dashboard.example.com/customers?s=Ahmad
, the user will need to retype Ahmad
each time... Also they cannot bookmark nor share a filter state, a page's tab, or a data table pagination position.
Sometimes, it's challenging to put complex objects in the URL with the browser's URLSearchParams
API, still it's more than enough for a lof of cases, and can help improve the UX with very little work done, if you're using React router or Next.js, you can use useSearchParams
hook and you will find something similar in every routing solution, which really won't feel different a lot of a normal useState
most of the time.
For more complex use cases, I believe Tanner Linsley's new library: TanStack Router is the way to go, please check it out if you still haven't, it's just as great as TanStack Query!
Keyboard navigation
In general, this is more critical for special needs users, but in a dashboard context, this is mostly needed when filling forms, which is very typical for a dashboard.
In which I mean navigating elements with Tab
keyboard's key, this shouldn't be hard to support if you're using any respectable UI components library, they all come with this built-in, still there's some ways you can mess this up:
Accidentally removing the outline style
This is necessary to indicate what element is currently being focused on, if this isn't visible (or hardly visible), keyboard navigation would be impossible.
Nesting clickables
Common scenario is you want a link that looks like a button, so what you do is:
<Link href="/...">
<Button>
A Link Button!
</Button>
</Link>
First issue of this is that it's semantically incorrect, second is that it will break keyboard navigation, as the first tab you would focus on the link element, the second one you will focus on the button, now consider this for a page full of links that do that, not much fun.
the fix for this is quite easy, just style the link with the button style, the result is exactly the same, all UI component libraries that I know of have this built in, MUI for example:
<Button LinkComponent={Link} href="/Login">
Login
</Button>
or shadcn/ui:
<Button asChild>
<Link href="/login">Login</Link>
</Button>
for other situations that you're forced to do this, use tabindex="-1"
for elements that shouldn't be navigable.
Buttons should be buttons, links should be links
What is worse than having a link nested in a button? not having the link at all! I yet to find a justifiable reason to do:
<Button onClick={() => navigate("/i-am-a-link-actually")}>
Button
</Button>
There's a lot of issues with this, the most annoying for me:
- I don't know where this button will take me (no url preview).
- I can't open the link in a new page (
CTRL + Click
), which is really common to do (multitasking).
The same goes for buttons you don't want to have a button styling, you should never use <div />
as a button, there's always better alternatives, in MUI, for example, you can either:
- Use ButtonBase component.
- Or if don't want any styling at all you can use
Box
component (and remove any unwanted styling):
<Box component="button">Button</Box>
NOTE: you may be thinking why not just use <button/>
, we can, but now we won't be able to access the library's styling system and theme.
if you still need to use a div for some reason, at least make sure that it's tabbable (tabindex="0"
) and there's a visual indication when the button is focused.
Always have fresh data
Dashboards always have in common that the data is dynamic, it would be sensible to make sure that your data is always up-to-date and let the user have trust in your app, and not refresh the page after any action they do.
if you're using Tanstack query, that's a very good start! it comes with pretty good defaults, you won't need to do much to always have fresh data (and still have good caching implemented), but you still need to make good use of its features:
Use a well-designed query keys system
I posted about it before, basically make it easy for yourself to find what queries to invalidate or refetch, if you usually throw your useQuery
s around in your components with random query keys... I can assure you that you won't bother to do any invalidation, or if you do, you have to check what the keys for each query and type them manually, which is error-prone (or worse yet, just force a page refresh!), to avoid all that, just keep track of your query keys (or tags if you're using Next.js) in a type-safe way and allow invalidation to be done in a few keystrokes.
One way to do it that I found pleasant to work with:
import {
addPet,
deletePet,
findPetsByStatus,
getPetById,
} from "@/services/pet";
import { createQueryKeys } from "@lukemorales/query-key-factory";
import { useMutation, useQuery } from "@tanstack/react-query";
type PetFindByStatus = Parameters<typeof findPetsByStatus>;
type PetDetailsParameters = Parameters<typeof getPetById>;
export const petKeys = createQueryKeys("pet", {
findByStatus: (...params: PetFindByStatus) => ({
queryFn: findPetsByStatus(...params),
queryKey: [params],
}),
details: (...params: PetDetailsParameters) => ({
queryFn: getPetById(...params),
queryKey: [params],
}),
});
export const petQueries = {
useFindByStatus: (...params: PetFindByStatus) =>
useQuery({ ...petKeys.findByStatus(...params), staleTime: Infinity }),
useDetails: (...params: PetDetailsParameters) =>
useQuery(petKeys.details(...params)),
useAdd: () =>
useMutation({
mutationFn: addPet,
}),
useDelete: () => useMutation({ mutationFn: deletePet }),
};
A new pet was added and we want to reflect that in our pets data table, we can invalidate the findByStatus query to do that like this:
// for a specific pet status
queryClient.invalidateQueries({
queryKey: petKeys.findByStatus({ status: ["available"] }).queryKey,
});
// the type of queryKey here is: readonly ["pet",
// "findByStatus", [params: FindPetsByStatusParams, options?: RequestInit | undefined]]
// ...or for all possible status
queryClient.invalidateQueries({ queryKey: petKeys.findByStatus._def });
// the type of queryKey here is: readonly ["pet", "findByStatus"]
Pretty cool right?
NOTE 1: I'm using orval.dev here to generate the API functions from the swagger spec, give it a look.
NOTE 2: You may see the code above a little bit messy (a lot of ...
usage), and I kinda agree, I believe it can be better, but for now I think this is a good balance between productivity, readability and maintenance, I'm still in the process of improving this, so I might update this section in the future.
Update 2024-08-24 I started using The Query Options API recently, and the type-safety that it provides is enough to adopt it. The previous code but using
queryOptions
:
import {
addPet,
deletePet,
findPetsByStatus,
getPetById,
} from "@/services/pet";
import { createQueryKeys } from "@lukemorales/query-key-factory";
import { queryOptions, useMutation } from "@tanstack/react-query";
type PetFindByStatus = Parameters<typeof findPetsByStatus>;
type PetDetailsParameters = Parameters<typeof getPetById>;
export const petQueries = {
rootKey: () => ["pet"],
findByStatus: (
...params: PetFindByStatus
) =>
queryOptions({
queryKey: [...petQueries.rootKey(), "findByStatus", params],
queryFn: () => findPetsByStatus(...params),
}),
details: (
params: PetDetailsParameters,
) =>
queryOptions({
queryKey: [...petQueries.rootKey(), "details", params],
queryFn: () => getPetById(params),
}),
};
export const petMutation = {
useAdd: () =>
useMutation({
mutationFn: addPet,
}),
useDelete: () => useMutation({ mutationFn: deletePet }),
};
To read on why this is a better approach to write queries, check out The Query Options API by TkDodo.
Conclusion
These were a few pain points I found in some dashboard applications I worked on, and a most of them doesn't actually require a lot of effort from you, but still very easy to ignore, and would be hard to reverse after the codebase become large enough, so just be thoughtful from the start, and try to ensure they are addressed from the get-go!