Fork me on GitHub

Hey, I just wanted to share with you that I built a nifty little HOC to connect a hook to a component and receive its returned value as props. This helps cleanup your Function Components since you can split your states and effects into multiple hooks and simplify your code.

Imagine you have the following component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const MyComponent = ({ userId }) => {
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [result, setResult] = useState(null);

  const handlePressButton = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);

      const result = await fetchDataFromTheAPI(userId);

      setResult(result);
    } catch(error) {
      setError(error);
    } finally {
      setLoading(false);
    }
  }, []);

  const [count, setCount] = useState(0);

  const handlePressCount = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <>
      {error && <RenderError error={error} />}
      {isLoading && <Spinner />}
      {result && <RenderResult result={result}>}
      <Counter
        count={count}
        onPress={handlePressCount}
      />
      <Button onPress={handlePressButton} />
    </>
  )
};

The code may look as clean as possible, but it could be better organized, and this is how you can do that.

Introducing Hooker

Hooker is a HOC that sends props to a hook as argument and return its value as props to the component. This way we can separate the previous component into two custom hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// hooks.js
const useButtonHandler = ({ userId }) => {
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [result, setResult] = useState(null);

  const handlePressButton = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);

      const result = await fetchDataFromTheAPI(userId);

      setResult(result);
    } catch(error) {
      setError(error);
    } finally {
      setLoading(false);
    }
  }, []);

  return { isLoading, error, result, handlePressButton };
};

const useCounter = () => {
  const [count, setCount] = useState(0);

  const handlePressCount = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return { count, handlePressCount };
};

The return types will be merged with the previous props and the whole thing will be sent as props to the component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// MyComponent.js
const MyComponent = ({
  isLoading,
  error,
  result,
  handlePressButton,
  count,
  handlePressCount
}) => (
  <>
    {error && <RenderError error={error} />}
    {isLoading && <Spinner />}
    {result && <RenderResult result={result}>}
    <Counter
      count={count}
      onPress={handlePressCount}
    />
    <Button onPress={handlePressButton} />
  </>
);

const enhance = compose(
  hooker(useButtonHandler),
  hooker(useCounter),
);

export default enhance(MyComponent);

If you’re used with HOCs or recompose, you should be familiar with HOC composition. If you’ve never seen that before, know that compose is a curry function and that the order of the arguments affects the final result.

Hooks that receive arguments

Your hooks will receive previous props as its arguments, that’s why it’s important to be mindful of the order of your HOCs. If one hook need a prop returned by another hook, the former needs to be passed after the latter. Let’s change useCountr, so that we can only increment our counter when it’s not loading:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// hooks.js
const useButtonHandler = ({ userId }) => {
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [result, setResult] = useState(null);

  const handlePressButton = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);

      const result = await fetchDataFromTheAPI(userId);

      setResult(result);
    } catch(error) {
      setError(error);
    } finally {
      setLoading(false);
    }
  }, []);

  return { isLoading, error, result, handlePressButton };
};

const useCounter = ({ isLoading }) => {
  const [count, setCount] = useState(0);

  const handlePressCount = useCallback(() => {
    if (!isLoading) {
      setCount(count + 1);
    }
  }, [count]);

  return { count, handlePressCount };
};
1
2
3
4
5
// MyComponent.js
const enhance = compose(
  hooker(useButtonHandler),
  hooker(useCounter),
);

Note that useCounter now receives an object with the attribute isLoading. Since we’re hooking it after useButtonHandler, it will receive whatever the latter is returning, which includes isLoading. If our compose was reversed, isLoading would be undefined in useCounter since it doesn’t exist yet.

Custom argument mapping

By default, hooker passes through to the hook any props it receives, however, if you need custom mapping of the props to arguments, you can send an optional function to do so:

1
2
3
4
5
6
7
8
9
10
11
12
// hooks.js
const useCounter = isLoading => {
  const [count, setCount] = useState(0);

  const handlePressCount = useCallback(() => {
    if (!isLoading) {
      setCount(count + 1);
    }
  }, [count]);

  return { count, handlePressCount };
};
1
2
3
4
5
// MyComponent.js
const enhance = compose(
  hooker(useButtonHandler),
  hooker(useCounter, ({ isLoading }) => isLoading),
);

useCounter now receives isLoading directly as the only argument instead of receiving an object, so we had to map the props to that argument in our call to hooker.

Typescript ❤️

If you’re awesome and use Typescript, you’ll be glad to know that hooker is fully compatible with Typescript. Actually, hooker was implemented considering Typescript as first-class citizen.

Pro-tip: if you need to merge your props with the values returned by your hook, a good idea is to use Typescript’s build-in ReturnType<>:

1
2
3
4
5
6
7
8
// hooks.js
const useCounter = () => {
  ...

  return { count, handlePressCount };
};

export type CounterHookProps = ReturnType<typeof useCounter>;
1
2
3
4
5
6
7
8
9
10
11
12
13
// MyComponent.js
interface Props {
  name: string;
  userId: number;
}

type HookedProps = Props & CounterHookProps;

const MyComponent = ({ name, count }: HookedProps) => (
  ...
);

export default hooker(useCounter)(MyComponent);

An easier way to record iOS gifs

One of these days, I needed a tool to record gifs from iOS devices to provide a better descriptionto our PRs here at Work&Co. It wasn’t …… Continue reading

Redux sucks with React-Native

Published on November 23, 2018