The problem with functional components


Over the last years, with the introduction of functional components and hooks, React has gradually been phasing out class components. Although I appreciate the introduction of functional components for their simplicity & conciseness, I think that removing class components was the wrong decision.

Before going further, let’s establish one thing: closures and classes are two ways to look at the same thing: it’s a pointer to a set of fields & methods. For classes, we call this pointer this. For closures, we call this pointer the closure context. In theory, both constructs are interchangeable, so we should be free to use whichever one is more adapted to the situation at hand. In the following sections, I’ll be arguing that in some cases, being able to use classes is more beneficial than using closures. Essentially, simple components are better written as closures, whereas complex components with more state are better written as classes.

Let’s look at an example. Below we have equivalent drafts for a Selector component in functional and class versions.

class Selector extends Component {
  // fields
  id = cuid()
  ref = createRef()
  position = { x: 0, y: 0}
 
  // state
  state = {
    open: false,
    loading: false,
    disabled: false,
    selectedIndex: -1,
  }
 
  // methods
  open() {}
  close() {}
  select(index) {}
  focus() {}
 
  // result
  render() {
    return (
      /* ... */
    )
  }
}
function Selector(props) {
  // fields
  const id = useRef(cuid())
  const ref = useRef()
  const position = useRef({ x: 0, y: 0 })
 
  // state
  const [open, setOpen] =
    useState(false)
  const [loading, setLoading] =
    useState(false)
  const [disabled, setDisabled] =
    useState(false)
  const [selectedIndex, setSelectedIndex] =
    useState(-1)
 
  // methods
  const open = useCallback(() => {}, [])
  const close = useCallback(() => {}, [])
  const select = useCallback((index) => {}, [])
  const focus = useCallback(() => {}, [])
 
  // result
  return (
    /* ... */
  )
}

Issue #1: Readability is worst

Whereas the class version uses idiomatic standard javascript syntax, the functional one replicates the same structure by mashing every field, state and method into one big function, where method declaration, field initialization and UI rendering intermingle into one ungodly mess. Class syntax provides me language-backed guarantees that when I look at a method, be it a(), b() or render(), no control flow outside of the function can affect the control flow inside the function and no variable declared inside the function can be used anywhere else. There is a guaranteed encapsulation. I know where the a part is, I know where the render part is.

For the functional version, all the state and temporary variables are stored in the same lexical context. If you’re lucky, the component follows the same structure than it would if it was a class, and temporary variables are declared next to the function where they’re used. If you’re not, they’re interspersed a bit everywhere. Some rendering done here. Oh and some more done after we’ve defined this new function. Oh. ho. o. !.

And you need to wrap everything in useState, useMemo and useCallback. Every field. Every method. Everywhere.

And then comes the problem of passing part of the internal state of the component around. Say you want to decouple part of the processing to functions in another file. How do you pass the internal state of the component around? For class components, that’s trivial: pass this. For functional components, this is instead the closure context which is… a data structure accessible only to the JS engine :| So what do you do? Either create bag-of-data objects from the state (essentially duplicating the closure context in memory): externalFn({ open, loading, select }). Or mash more functions into the big function.

And the worst? I lied above. To be 100% equivalent you’d need to wrap with forwardRef and expose the methods with useImperativeHandle:

const Selector = forwardRef(function Selector(props, ref) {
  /* same as above: fields, state, methods */
 
  useImperativeHandle(ref, () => ({
    open,
    close,
    select,
    focus,
  }), [])
 
  /* same as above: result */
})
 

Issue #2: DX is worst

NOTE: You appear to be on firefox. This section doesn’t apply for your browser.

Let’s say you’re debugging some bug that happens in response to an event. The handler calls down some function. Which calls another. Few levels more. You know where the bug happens. You’ve set your debugger breakpoint, deep down in the stack. What does your call stack look like?

Yup. useCallback prevents the function from being assigned directly to a variable, so it’s anonymous and the engine can’t guess it’s name*. This is what you get in the devtools everytime you look at your call stack. Wanna figure what calls resulted in this bug? Go look at every entry of the stack manually.

* SpiderMonkey is the only exception :O V8 and JSC can’t.

Issue #3: Performance is worst

This one is obvious. Every time your component re-renders, it needs to make:

/* +1 allocation: return array */
const [state, setState] = useState(false)
 
/* +2 allocations: inline closure, dependency array */
const method = useCallback(() => {}, [])
 
/* +2 allocations: inline closure, dependency array */
const derived = useMemo(() => {}, [props.data])

In addition to those, which are “React’s fault”, there are also just more allocations by default because there is no differentiation between initialization (like you know, a constructor()) and rendering, so people keep doing allocations like this one:

/* +1 allocation: inline object */
const position = useRef({ x: 0, y: 0})

Anyway let’s test it with my poor man’s implementation of react hooks. It’s accurate in terms of performance overhead, the react hooks implementation also uses a linked list (click Show Context to see the implementation).

Alright, I’ll admit that the benchmark here is a bit of a stretch, and that the impact for the large majority of applications is irrelevant. But in some cases, it does. You’re running this on a fast device, what would be the result on a low-end device? What happens when you do get to those stretching use-cases? Then you don’t have a choice but to use the less performant functional components.

Of course if you have a (real) functional component that is just a pure function Component :: props -> UI with no state & hooks, then for sure the functional one is faster. Functional components do have a place, they’re excellent for simple use-cases. But there’s more than just simple use-cases in life.

Final notes

I wish React would keep class components. But it seems like the maintainers in the last years are forcefully making it impossible to shoot yourself in the foot and make it so there is One Right Way to do things (see the removal of componentWillReceiveProps). Which makes sense, from their point of view. They’re maintaining a library with a huge reach. They get all sort of reports from people with very varying levels of skill. So the biggest problem from their perspective is to ensure that most people are using the library correctly. And that means making things as simple as possible so those with less experience can use it correctly. They baby-proof it. The thing is, by doing so they make it harder to solve hard problems.





Addendum: “but they’re not removing class components”

Any code written with hooks cannot be used from class components, therefore it’s not possible to write new code with classes because the default style is functional components. The maintainers have also stopped making things usable. It’s not possible to use more than one context in a class component (at least in an ergonomic way). And all the new improvements (e.g. useEffect: running something when a specific set of state/props changes) are also not included in the class components API. So yes, they’re removing them.