Hey everyone!
I’ve been struggling for the last few days with the right way to check that certain preconditions are met when rendering a cell or a page. And I think I’ve come up with a pattern that feels both robust and generally useful that I’d like feedback on.
Short version
I wrote a new hook, useGuard
, with the following type signature:
function useGuard(
condition: any,
redirectTo: string | (() => string),
options?: NavigateOptions
): asserts condition
the useGuard
hook checks that the provided condition is true. If it is, rendering continues as normal. If it is not, it throws a specially formed GuardError
that includes the redirect link. The GuardError
is caught by a React error boundary higher in the tree (concretely, in my app layout), which then navigates to the redirectTo
target.
More Details
As a concrete example, here’s an example of a guard that would ensure the there isn’t already a user signed in (for use on a sign-up page, for example):
const SignUpPage = () => {
const { currentUser } = useAuth()
useGuard(!currentUser, routes.userDashboard())
// Render the sign-up UI here
}
Of course, there are existing ways to handle this. One common one might be the following pattern:
const SignUpPage = () => {
const { currentUser } = useAuth()
if (currentUser) return <Redirect to={routes.userDashboard()} />
// Render the sign-up UI here
}
Seems pretty equivalent, right? However, guard clauses have two major advantages over this early-return pattern:
First, the early return pattern can and will break if you want to use any hooks after the early return. currentUser
might load dynamically, in which case it will initially be undefined
and get past the early return, and then later be populated and trigger the early return. But if you use any hooks (state, callbacks, etc) below the early return, this will throw a Rendered fewer hooks than expected error which breaks the page. The useGuard
clause uses errors and error handlers to get around this, similar to how React Suspense works.
Second, guard clauses are inherently composable and encapsulable in a way early returns aren’t. Let’s say you have a complex multi-page signup flow and want to make sure a user who lands on any of the signup pages is redirected correctly. You could define the following custom hook, and just include it at the top of all of your signup pages:
// Define the custom hook
const requireNoExistingProfile = () => {
const { currentUser } = useAuth()
useGuard(!currentUser, routes.userDashboard())
}
// Use it on whatever page needs it
const SignUpPage = () => {
requireNoExistingProfile()
// ...
}
You can’t do the same thing with early returns because there’s no way for a called function to trigger a return in its parent (except by throwing an exception, like the useGuard
hook does).
I’ve written a working implementation of guards in my own project here, and already found it very handy. That said, I’d be very interested in the community’s feedback on this pattern, and its inclusion in Redwood itself if others find the concept useful.