DEV Community

Colin Gourlay
Colin Gourlay

Posted on

React's Hooks could be more portable

I've spent a while getting to grips with the React's new hotness, and I'm absolutely sold on it. I don't see myself refactoring my old class-based projects any time soon, but for future projects, I'm all about the hooks.

I read Dan Abramov's excellent deep dive on useEffect yesterday, but this morning I woke up with an itch in my brain, and wrote a small library called portable-hooks that scratches it.

Before I explain what it does, let's look at some code:

import React from 'react';
import { useEffect } from 'portable-hooks';

function App({ text }) {
  useEffect(() => {
    document.title = text;
  }, [text]);

  return <h1>{text}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

"Wait, that's how React's useEffect hook already works!"

Yeah, but what if you wanted to move that effect function outside the component, so you can use in elsewhere? React's existing useEffect hook leverages the component function closure to use the current props & state. This effectively traps effect functions inside the component. If you wanted to extract the effect that sets document.title, you'd have to do this:

import React, { useEffect } from 'react';

function setDocumentTitle(title) {
  document.title = title;
}

function App({ text }) {
  useEffect(() => setDocumentTitle(text), [text]);

  return <h1>{text}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Notice that, if you're correctly managing dependencies, you have to write text in two places:

  1. As an argument to setDocumentTitle, and
  2. In the dependencies array (useEffect's 2nd argument)

Why are we doing this? Functions arguments are dependencies, by their very nature.

React is asking us to write out these arguments twice every time we use one of these dependency-based hooks, if we want to avoid bugs. Wouldn't it be more concise to only write them in one place:

import React from 'react';
import { useEffect } from 'portable-hooks';

function setDocumentTitle(title) {
  document.title = title;
}

function App({ text }) {
  useEffect(setDocumentTitle, [text]);

  return <h1>{text}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

"What's going on here?"

The portable-hooks package provides wrapped versions of React's own hooks, which call your functions with the dependencies as their arguments. I don't know about you, but that seems pretty elegant to me. Now, your function signature and your dependencies are the very same thing, and you're less likely to run into bugs.

This lets us do cool things... like "effect props"

Wouldn't it be great to customise components by passing in effects:

import axios from 'axios';
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { useEffect } from 'portable-hooks';

function App({dataURL, fetchData}) {
  const [data, setData] = useState(null);

  useEffect(fetchData, [dataURL, setData]);

  return <div>{data ? <div>{
    /* use `data` for something here */
  }</div> : 'Loading...'}</div>;
}

async function fetchDataUsingAxios(url, setData) {
  const result = await axios(url);

  setData(result.data);
}

ReactDOM.render(<App
  dataURL="https://..."
  fetchData={fetchDataUsingAxios} />, document.body);
Enter fullscreen mode Exit fullscreen mode

Now you have a component that expects its fetchData prop to be a function that matches a certain signature, but you can implement that function in any way you want.

*ahem* "Excuse me, but sometimes I wanna lie to useEffect about what's changed"

Look, lying about dependencies is a bad idea, and portable-hooks very much encourages you (by design) to not lie about dependencies, buuuuut in rare cases it is actually useful. Don't worry though, I got you covered.

Each hook in portable-hooks differs from React's version by caring about one extra optional argument. If you set it, React's hook will use this as its dependency list, and the original inputs will still be passed into your function.

Here's a (very contrived) example which will spam the console from the moment the component mounts to the moment it is unmounted, regardless of the number of times it is updated:

import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { useEffect } from 'portable-hooks';

function logMountDuration(x) {
  let seconds = 0;

  const id = setInterval(() => {
    seconds++;
    console.log(`"${x}" was mounted ${seconds} seconds ago`);
  }, 1000);

  return () => clearInterval(id);
}

function App({ text }) {
  const [count, setCount] = useState(0);

  useEffect(logMountDuration, [text], []);

  return (
    <div>
      <h1>{text}</h1>
      <button onClick={() => setCount(count + 1)}>
        {`I've been pressed `}
        {count}
        {` times`}
      </button>
    </div>
  );
}

ReactDOM.render(<App text="Example" />, document.body);

// > "Example" was mounted 1 seconds ago
// > "Example" was mounted 2 seconds ago
// > "Example" was mounted 3 seconds ago
// ...
Enter fullscreen mode Exit fullscreen mode

API

portable-hooks exports the following hooks (which all care about dependencies):

  • useCallback
  • useEffect
  • useImperativeHandle
  • useLayoutEffect
  • useMemo

As explained earlier, they're all wrappers around React's own hooks, and expose the same API (with an additional optional argument for those situations where you wanna lie about dependencies), so you can use them interchangeably.

This means that all of your existing anonymous-argument-less code is already compatible, and you can start a refactor by updating your imports:

import React, { useEffect } from 'react';

// ...becomes...

import React from 'react';
import { useEffect } from 'portable-hooks';
Enter fullscreen mode Exit fullscreen mode

Please let me know your thoughts below. You can check out portable-hooks on GitHub or npm install portable-hooks to give them a try. Thanks for reading!

GitHub logo colingourlay / portable-hooks

Wrappers for React's hooks that make them more portable

portable-hooks

Wrappers for React's hooks that make them more portable

Read the introductory post on dev.to

$ npm i portable-hooks
Enter fullscreen mode Exit fullscreen mode

Usage

import React from 'react';
import { useEffect } from 'portable-hooks';

function App({ text }) {
  useEffect(() => {
    document.title = text;
  }, [text]);

  return <h1>{text}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

"Wait, that's how React's useEffect already works!"

Yeah, but what if you wanted to move that effect function outside the component, so you can use in elsewhere? React's existing useEffect hook leverages the component function closure to use the current props & state. This effectively traps effect functions inside the component. If you wanted to extract the effect that sets document.title, you'd have to do this:

import React, { useEffect } from 'react';
function
…
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
dance2die profile image
Sung M. Kim • Edited

This is pretty cool, Colin πŸ™‚

"Syntactically", it looks like,
React.useEffect is to portable-hooks.useEffect
as useMemo is to useCallback but much more πŸ˜‰

Collapse
 
scriptkavi profile image
ScriptKavi

Many early birds have already started using this custom hooks library
in their ReactJs/NextJs project.

Have you started using it?

scriptkavi/hooks

PS: Don't be a late bloomer :P