Let’s face it. Reactive programming and the traditional web APIs are not friends. Event handlers, observers, dimensions, all of these things are meant to be used imperatively using callbacks. This doesn’t play so well with reactive programming and can leave our components littered with callbacks and extra state. When was the last time you had to keep track of scroll position, blur a field, or check an element’s size? Probably pretty recently, I would bet. In this post, we are going to see how we can take what we know about reactive programming patterns and apply it in such a way that we can reduce and hide away our callbacks into wonderful little reusable hooks.

There are many DOM APIs, so this will be a multi-part series. We’ll kick things off with two heavy hitters: focus and scrolling.

Disclaimer: We will be using React in this post because it is so popular and we want to reach as wide an audience as possible, but these same principles apply to other reactive frameworks as well (have you tried Dojo?)

How to Reduce or Hide Callbacks in React Programming with Hooks

Focus/Blur

Situation: We have a component that makes some decisions based on whether or not it is in focus.

Solution: By creating a custom hook, we can remove the focus tracking from our component and create a reusable solution for tracking focus in any component.

Let’s say we have an input field that displays some additional guidance when the field is in focus. Something like this,

export const GuidedInput = () => {
  const [value, setValue] = useState("");
  const [focused, setFocused] = useState(false);

  return (
    <div>
      <input
        type="text"
        value={value}
        onInput={(ev) => setValue(ev.target.value)}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
      />
      {focused && <div>Format yyyy-mm-dd</div>}
    </div>
  );
};

Check it out in its full experience.

This is simple enough. But what if we want more fields to provide a similar experience, say a guided date picker, or a guided rich text area? We’d have to duplicate our focus tracking logic in each component, adding additional state and testing complexity.

Using React hooks, we can bundle up our state into a small hook:

const useFocus = (ref) => {
  const [focused, setFocused] = useState(false);

  // 1
  useEffect(() => {
    if (ref.current) {
      const hasFocus = (n) =>
        document.activeElement === n || n.contains(document.activeElement);
      const node = ref.current;

      // 2
      const handler = () => {
        if (hasFocus(node) && !focused) {
          setFocused(true);
        } else if (!hasFocus(node) && focused) {
          setFocused(false);
        }
      };

      // 3
      node.addEventListener("focus", handler);
      node.addEventListener("blur", handler);

      // 4
      return () => {
        node.removeEventListener("focus", handler);
        node.removeEventListener("blur", handler);
      };
    }
  }, [ref, focused]);

  return focused;
};

OK, what is going on here? Let’s go through this step by step.

  1. We’re using useEffect to run some code whenever the ref or focused state changes. This will ensure that if a component needs to destroy and recreate DOM nodes (maybe from a key change), we’re always working with the right nodes.
  2. We define a focus/blur handler that will get run on the node’s focus and blur events. Like in the original example, we are simply updating our state based on whether or not the node is in focus.
  3. We are using the imperative DOM APIs to add focus and blur event listeners with our handler.
  4. We want to make sure we clean up our event listeners!

Effectively, we’ve taken the part of our component that deals with DOM APIs and bundled it up into a single hook. We can now use this hook in any component we wish without keeping track of additional state! This is the crux of what we’ll be doing in all of our examples. We’ll be bundling up the DOM APIs into convenient hooks so we don’t ever see them in our components.

Take a look at our updated example.

export const GuidedInput = () => {
  const [value, setValue] = useState("");
  const ref = useRef();
  const focused = useFocus(ref);

  return (
    <div>
      <input
        ref={ref}
        type="text"
        value={value}
        onInput={(ev) => setValue(ev.target.value)}
      />
      {focused && <div>Format yyyy-mm-dd</div>}
    </div>
  );
};

See how much clearer that is? Instead of focusing (bad pun) on how focus is tracked, we are only concerned with what our component does when it is or isn’t focused.

You may have noticed that our focus handler is doing more than simple “is this element” focused. We’ve extended our focus handling to determine if our element is focused or any element inside our element is focused. This extension to our functionality lets us create even more sophisticated guided inputs with no additional efforts.

Scrolling

Situation: We have a component that needs to know the scroll progress of another element, whether this be the document body or any other element.

Solution: We can hide our scroll handling to a custom hook, thereby leaving our components as reactive as possible.

So we’ve got a site with a cool header. One of those really hip, tall headers, but we want the navigation on the header to get sticky as the user scrolls past it, so it’s always available.

Something like this.

We’re doing this by adding an event listener to the document, comparing the document scroll position with the top of our sticky header, and setting state if the header needs to be sticky. Look at all this imperative code!

export default function App() {
  const [fixedHeader, setFixedHeader] = useState(false);

  useEffect(() => {
    const scroller = () => {
      const top = window.scrollY;

      if (!fixedHeader && top >= 100) {
        setFixedHeader(true);
      } else if (fixedHeader && top < 100) {
        setFixedHeader(false);
      }
    };

    document.addEventListener("scroll", scroller);

    return () => document.removeEventListener("scroll", scroller);
  }, [fixedHeader]);

  return (
    <div className="App">
      <header className={`header`}>
        <h1>Title</h1>
        <div class={`tabs ${fixedHeader ? "fixed" : ""}`}>Sticky Header</div>
      </header>
      {Array.from(Array(5)).map(() => (
        <div className="content-block" />
      ))}
    </div>
  );
}

Taking inspiration from our focus example, we know we can hide away the document scroll listener into another hook, and only use the scroll position to make decisions about our header.

With these goals in mind, let’s write our new scrolling hook.

export const useScroll = (
  domNode
) => {
  // 1
  const [scrollY, setScrollY] = useState(0);

  // 2
  useEffect(() => {
    const scroller = () => {
      setScrollY(domNode.scrollY || domNode.scrollTop);
    };

    domNode.addEventListener("scroll", scroller);

    return () => domNode.removeEventListener("scroll", scroller);
  }, [domNode]);

  // 3
  return scrollY;
};

Let’s go over the details,

  1. We store the scroll position in state. This allows us to re-render our component on scroll, as well as have access to the latest recorded scroll position.
  2. As with our focus handler, we use useEffect to register/clean up our event listener. This is an easy one, all we want to do is record the last known scroll position.
  3. As simple as this is, this is the crux of the whole hook. By always returning the latest scroll position, we can make reactive decisions on every render.

Using this hook, our fixed header component is much easier to understand!

export default function App() {
  const fixedHeader = useScroll(window) >= 100;

  return (
    <div className="App">
      <header className={`header`}>
        <h1>Title</h1>
        <div className={`tabs ${fixedHeader ? "fixed" : ""}`}>
          Sticky Header
        </div>
      </header>
      {Array.from(Array(5)).map((_, index) => (
        <div key={`block-${index}`} className="content-block" />
      ))}
    </div>
  );
}

Consider for a moment how easy it is to test this. We simply mock useScroll and have it return two different values to test the two headers. This is much easier to test than our last version, which would have involved simulating scroll events and waiting for the component to update.

Check out the completed version here.

We’ve created a reusable hook here, but in our case, I think we can take this even further. Every time the user scrolls, our component is going to re-render, as our hook doesn’t know the business rules under which it is being run. In our example that’s not a big deal, it’s so simple, but in the real world that might cause a serious performance issue. What if, instead of returning a scroll position, it told us exactly whether or not our component needed a fixed header?

export const useFixedHeader = (domNode, threshold) => {
  const [result, setResult] = useState(false);

  useEffect(() => {
    const scroller = () => {
      const scrollTop = domNode.scrollY || domNode.scrollTop;

      const latestResult = scrollTop >= threshold;
      if (latestResult !== result) {
        setResult(latestResult);
      }
    };

    domNode.addEventListener("scroll", scroller);
    scroller();

    return () => domNode.removeEventListener("scroll", scroller);
  }, [domNode, threshold, result]);

  return result;
};

// ...

export default function App() {
  const fixedHeader = useFixedHeader(window, 100); // wow!

  return (
    <div className="App">
      <header className={`header`}>
        <h1>Title</h1>
        <div className={`tabs ${fixedHeader ? "fixed" : ""}`}>
          Sticky Header
        </div>
      </header>
      {Array.from(Array(5)).map((_, index) => (
        <div key={`block-${index}`} className="content-block" />
      ))}
    </div>
  );
}

Now we’ve done it! Our component won’t re-render unless the header needs to change. This will ensure the smoothest scroll experience we can for the user.

Conclusion

You’ve probably figured out the pattern that we’ve followed while solving these problems. We’ve extracted our DOM API callbacks into reusable React hooks that we can plug into other components. These help us produce reactive components that are only making the decisions we care about, rather than wading through all the cruft of DOM API access.

We have gone through two examples of reducing callbacks, but these same principles can be applied to other APIs as well. In our next installment of this series we’ll take a look at two more of our favorite APIs: Intersection Observer and element dimensions. Stay tuned! ☮️