Feature proposal: `useGuard` hook for checking preconditions on page render

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.

2 Likes

I still need to sit down and not read through this on my phone, but it looks like it’s going to make its way into my app - replacing my existing useAuth hook for redirecting authenticated users trying to sign up or login again.

Big thanks for the ambiguity! I’d +1 it’s inclusion in the framework, maybe with a new name - if I had to nitpick. Just by looking at the name, I’m not sure what we’re guarding against.

Is there the potential this can be used anywhere in the component tree? Not just on pages but on lowly components and cells too?

I’d be totally open to another name. It’s a reference to the concept of a guard clause, but I agree if that isn’t top of mind it might be hard to make the connection.

Alternative names might be useCheck, useAssert, usePrecondition, usePrecheck, or something else?

cc @Tobbe @dom

Interesting … I like the fact that it will order well with the hooks. I don’t think we’ve fought that case yet. However, we are using custom hooks for various controller functions, and I’ll bet if we looked, there are cases we aren’t guarding against.

It would be great to have this in the toolbelt! Thanks, Kyle!

As for “guards” in my first proper Jamstack app with lambda serverless and React, the studio I used for the front end app dev had to build React Router Guards to help on the web side even as we did auth with Auth0. This was February 2019, but it’s still around:

We’re building exactly this at my day job (old Create-React-App project with React Router v5). We keep the signup state in a React Context. On every page of the signup, the first thing we’d do up top was to look at the context state and see if the data was there from a previous state. If not we’d redirect to the first page of signup.

Basically we’d have something like this

  useEffect(() => {
    if (typeof signupContext.state.email === "undefined") {
      history.push(signupUrl);
    }
  }, [signupUrl, history, signupContext.state.email]);

We later extracted that out, and called it a “direct access guard”

Now, since we’re using React Router what we did, instead of making this a hook we’d need to remember to include on every page, we put it in our own custom Route component.

So instead of (semi-pseudo code)

<Router>
  <Route path={introPath}>
    <IntroPage />
  </Route>
  <Route path={experiencePath}>
    <ExperiencePage />
  </Route>
  <Route path={crsPath}>
    <CrsPage />
  </Route>
</Router>

Instead of that we did

<Router>
  <Route path={introPath}>
    <IntroPage />
  </Route>
  <NoAccessGuardedRoute path={experiencePath}>
    <ExperiencePage />
  </NoAccessGuardedRoute>
  <NoAccessGuardedRoute path={crsPath}>
    <CrsPage />
  </NoAccessGuardedRoute>
</Router>
interface Props {
  children: ReactNode;
}

export const DirectAccessGuard: React.FC<Props> = ({ children }) => {
  const signupContext = useContext(SignupContext);
  const history = useHistory();

  useEffect(() => {
    if (typeof signupContext.state.email === "undefined") {
      history.push(signupUrl);
    }
  }, [signupUrl, history, signupContext.state.email]);

  return <>{children}</>;
};

export const NoDirectAccessRoute: FunctionComponent<RouteProps> = ({
  children,
  ...props
}) => (
  <Route {...props}>
    <DirectAccessGuard>{children}</DirectAccessGuard>
  </Route>
);

Redwood’s router doesn’t let you do that. But what Redwood has that ReactRouter doesn’t is <Set>s. I haven’t tested, but I think you should be able to take all your signup pages and just wrap them in a Set

<Router>
  <Set wrap={DirectAccessGuard}>
    <Route path={introPath} page={IntroPage] />
    <Route path={experiencePath} page={ExperiencePage} />
    <Route path={crsPath} page={CrsPage} />
  </Set>
</Router>

So, what did I want to say with all this?

  • I wanted to show a slightly different way to do something similar. To maybe get some more thoughts/ideas going
  • I wanted to validate that you’re not the only one who needs something like this
  • I wanted to validate the “guard” terminology
  • I wanted to let you all know I’m excited about this, and would love to help move this forward

Yes, this would work! And in fact in an earlier implementation I did exactly that. (I also tried implementing this with higher-order components that run the check before rendering the children.)

I think the wrapping-component pattern works fine if you have a small number of general-purpose guards, and they apply to logically connected groups of routes that you might want to wrap in a <Set> anyway. However, it’s a less flexible pattern than the useGuard hook because you probably wouldn’t bother creating a <Set> and a dedicated <Guard> component for a check that’s only used in one or two places.

Going back to the signup form example, on step 3 of the signup flow you might want to check both that the user doesn’t have an account and that they’ve already completed step 2. Extending the example from my initial post, you could write that check like this:

const SignUpStep3 = () => {
  requireNoExistingProfile()
  useGuard(formState.step2Fields != null, routes.signUpStep2())
  // ...
}

Admittedly this example is getting a bit contrived, but I hope it illustrates the idea!

:100:

I really like this.

Now, my next question is if this should be included in RW, or it should just live like a separate package outside of RW that just “happens” to work really well with RW.

Yep, that’s a good question. I think it probably makes sense to build it into Redwood directly, because at least in my case the implementation is tightly coupled to the router. Specifically, the error boundary has to know when the new route has been successfully loaded so it can clear the cached error and start showing the page again. You can see how I do that here.

Yeah, I see. Or we could do a proper, documented, export of that context.

The core team is going to take a higher level look at the router and I’ll be sure to bring this up! What would help is if you could come up with more concrete use cases than the signup process both you and I are familiar with. Thanks! :slight_smile:

That’s fair. Signup flow pages that involve complex prerequisites and assumptions about the current state are definitely where I’ve felt the most pain here.

As another use case, the app I’m building has a concept of public profiles that can be “challenged”. The route to challenge a profile looks like this:

<Route path="/profiles/{id}/challenge" page={ChallengeProfilePage} name="challengeProfile" />

However, not all profiles are in a challengable state. So within the ChallengeProfilePage, we use a query to pull the profile and then do some logic on it to ensure that it’s challengeable. If it isn’t, we redirect you back to the profile page with a toast message explaining the problem.

(In my implementation of useGuard, I also added the ability to include a toast message that will be displayed on the page you’re being redirected to, although I left that out of my explanation above for simplicity. I’ve found this pattern really useful though, and would advocate for adding it as an option to navigate and <Redirect />!)

+1 for maybe a different name. Guard clauses are more of a general usage typically (and honestly it reminds me too much of Elixir or Rust, lol). Maybe 2 custom hooks?

useGuard - for guard clause functionality in components

useProtectPage - specifically for protected pages? maybe useSecure? (this hook would use useGuard)

But maybe I’m overly complicating it. I like the pattern :slight_smile: