A fews days ago i found a reddit thread where people posted about "bad habits of react developers". "Prop drilling" was mentioned a few times, which reminded me of a problem I just recently came across at work: overusing useSelector to avoid prop drilling.

For some reason react devs are really scared of prop-drilling. They reach for useSelector too often and too carelessly, without thinking of the negative impact on their codebase.

Prop-drilling is a practice where you pass a data from one component to another, further down the component-tree.

I've set up a tiny react-typescript + redux example in codesandbox, that allows you to log in and visit a UserPage when logged in. The code in App.tsx and AppNoProps.tsx is functionally identical. AppNoProps.tsx is using useSelector over prop drilling and I'll walk you through why I prefer the implementation of App.tsx.

you might have to open the codesandbox in a full window, to get typescript working. for some reason it shows any instead of the real types when embedded 🙄

Lets dive into AppNoProps.tsx.

If we hover over user in const user = useSelector(...) in either App or UserPage we see that it is typed User | null. This is the case whenever we use useSelector.

But in <App> we only render <AuthenticatedApp> and its child <UserPage> if user is NOT null (truthy).

export function App() {
  const user = useSelector((state: RootState) => state.user.value);
 
  return (
    <div className="App">
      {user ? <AuthenticatedApp /> : <UnauthenticatedApp />}
    </div>
  );
}

Even though <UserPage> is only rendered if user is not null, we have to do null-checks all over again in <UserPage>. The fact that we already checked user for null in <App> does not help us at all.

Whenever we want to access the value of user we have to check for null again. That means adding if conditionals in every hook, and the return of our component.

function UserPage() {
  const dispatch = useDispatch();
  const user = useSelector((state: RootState) => state.user.value); // User | null
 
  useEffect(() => {
    if (!user) return; // 💩 we have to handle the nullable user, -> rules of hooks
    // do some react-external stuff with user
  }, [user]);
 
  // 💩 we know that user wont be null here, but we still have to handle it
  if (!user) {
    return null;
  }
 
  return (
    <div>
      <h1>hello {user.name}</h1>
      <button onClick={() => dispatch(logout())}>logout</button>
    </div>
  );
}

Please dont use a type assertion like const user = useSelector(...) as User as this just overrides/disables type checking.

In our small example, a few if statements might not look like much of a problem, but dont forget that we have to repeat these checks for any children of <UserPage> too. All the redundant if statements and optional chaining increase our cyclomatic complexity and make the flow of our code hard to follow.

Read about Cyclomatic Complexity.

so whats the alternative?

In <App> we already know for sure that user is either null or User, so by checking whether user is null, we can narrow its type further down the component tree. All we need to change is pass user down the subtree of <AuthenticatedApp>.

export function App() {
  const user = useSelector((state: RootState) => state.user.value);
 
  return (
    <div className="App">
      {user ? <AuthenticatedApp user={user} /> : <UnauthenticatedApp />}
    </div>
  );
}

Hover over the user value in <AuthenticatedApp user={user} /> and notice how it is typed as User. From here on out we can lean back and trust that we have user available.

Here's where the only drawback comes into play. Our <AuthenticatedApp> needs to accept an additional user prop, even though it doesnt do anything with it except passing it on to <UserProp>.

Lets check out <UserPage> and see what we've gained:

function UserPage({ user }: { user: User }) {
  const dispatch = useDispatch();
 
  useEffect(() => {
    // do some react-external stuff with user
    // 👍 no null-checks needed
  }, [user]);
 
  // 👍 only one return path
  return (
    <div>
      <h1>hello {user.name}</h1>
      <button onClick={() => dispatch(logout())}>logout</button>
    </div>
  );
}

No more null checks! Our useEffect can run without any if statements and we dont need an early return if user is null either. We have basically eliminated any need for null checks or optional chaining for the whole component tree below AuthenticatedApp.

tldr: Use useSelector to select state from the store at the highest point you need it, narrow its type to remove null as possible value and pass it down as prop. Its basically "lifting state up" but for redux.

Depending on the distance between where you first need the user and the last leaf node where you also need the user, you might not prop-drill all the way. Lets say our App were more deeply nested like App > AuthenticatedApp > Layout > SubLayout > UserPage > UserSettingsPage > UserForm. In this case I might not prop-drill the user value from <App> all the way down to UserForm. Instead I would decide whats the closest ancestor of <UserForm> that needs user and user a combination of prop-drilling and composition from there. Here the closest ancestor would probably be <UserPage>. That way I dont need to drill through <Layout> and <SubLayout>, but I still reduce the number of null-checks in the <UserPage> tree.

In development any "best practice" or "antipattern" come with tradeoffs, so it is on us to weigh the pros and cons and decide what works better for us.

That said, I would pick simple, readable code over loads of ifs and optional chaining any time, even if it means adding a few more props to components.