How can I use hooks in a higher-order function that returns a ReactNative functional component in TypeScript?

Viewed 77

Let me start with the working code that I'm trying to refactor. I'm trying to abstract button creation, and whatever I try, I keep getting the "hooks can only be called inside of the body of a functional component" error.

Following along with a tutorial, I've setup a navigation bar and a button that will take me from the home screen to a "Details" screen. Here's what works:

const DetailsButton = (): JSX.Element => {
  const navigation = useNavigation();

  return (
    <Button title="Details" onPress={() => navigation.navigate('Details')} />
  );
};

// And then later on...

<Stack.Screen name="Home" 
              component={HomeScreen} 
              options={{ title: 'Master', headerRight: DetailsButton }} />

That button will take me to the Details screen with no issue. But then I got to thinking that I could refactor this into a kind of button factory function:

interface BarButtonProps {
  title: string, 
  destination: string,
};

const BarButton = (buttonProps: BarButtonProps): () => JSX.Element => {
  const navigation = useNavigation();

  return (): JSX.Element => (
    <Button title={buttonProps.title} onPress={() => navigation.navigate(buttonProps.destination)} />
  );
}

const DetailsButton = BarButton({title: 'Details', destination: 'Details'});

My plan was to use the DetailsButton component as before. However, when I try to run the code, I get the hooks error. I thought I understood why, and so I tried the following variation:

const BarButton = (buttonProps: BarButtonProps): () => JSX.Element => {
  return (): JSX.Element => (
    <Button title={buttonProps.title} onPress={() => useNavigation().navigate(buttonProps.destination)} />
  );
}

In my mind, the hook now seemed to be within the functional component. But that only got me as far as the home screen. When I press the button I get the same hooks error again. I must be missing some subtle point.

Is it possible to create a higher-order function like this, one that uses a hook, or am I trying something impossible? If it's possible, can someone show me what would work? Thanks.

2 Answers

You were pretty close. Your factory should look like this:

const ButtonFactory = (props: ButtonProps): () => JSX.Element => {
  const { title, destination } = props;

  const MyButton = (): JSX.Element => {
    const navigation = useNavigation();

    return (
      <Button title={title} onPress={() => navigation.navigate(destination)} />
    );
  }

  return MyButton;
};

export const DetailsButton = ButtonFactory({ title: 'Details', destination: 'Details' })

The problem with the current setup is that the useNavigation hook is being executed at the point where you call BarButton({title: 'Details', destination: 'Details'}) rather than at the point where you call <DetailsButton/>.

When the BarButton function is called, you call useNavigation and then return a function component. This is a violation of the rules of hooks. You need to move the useNavigation hook so that it is called inside the created component.

Simply re-arrange the order of your lines:

const BarButton = (buttonProps: BarButtonProps): () => JSX.Element => {
    return (): JSX.Element => {
        const navigation = useNavigation();
        return (
            <Button title={buttonProps.title} onPress={() => navigation.navigate(buttonProps.destination)} />
        );
    }
}

Personally I would write the factory with a lowercase name to indicate that this is a function which creates a component rather than a component itself.

import * as React from 'react';
import { Button } from 'react-native';
import { useNavigation } from '@react-navigation/native';

interface BarButtonProps {
  title: string;
  destination: string;
}

const createBarButton = ({title, destination}: BarButtonProps) => (): JSX.Element => {
  const navigation = useNavigation();
  return (
    <Button
      title={title}
      onPress={() => navigation.navigate(destination)}
    />
  );
};

const DetailsButton = createBarButton({
  title: 'Details',
  destination: 'Details',
});
Related