Conceptual Gaps in Declarative Frontend Frameworks - Part 1 - All Props are Created Equal

Isaac Hagoel - Feb 18 '20 - - Dev Community

TLDR: Props cannot express what we (well.. at least I) need them to express

Introduction and Context

This article is meant to be a part of of a short series in which I point out some overlooked trade-offs made by the declarative approach to describing user interfaces.
The declarative approach is the de-facto standard in the industry and was adopted by the major frontend frameworks and even by the built-in vanilla web-components.
There are two main reasons I think this topic is worth exploring:

  1. The advantages of declarative programming in the context of frontend development are well understood and frequently mentioned but the disadvantages are rarely ever acknowledged.
  2. As far as I can tell, these disadvantages hold the community back from writing richer user interfaces and more expressive (readable, maintainable, effective) code.

I've used three web-frameworks (not at the same time :)) to build relatively large UIs: React (please stop calling it a library), Svelte 3 and Aurelia. They are all wonderful in their own ways but share the issue I am going to describe. I've also used vanilla javascript with custom elements, which allow working around this issue if you are willing to accept a whole bag of other issues :).

I have not used Vue, Ember, Angular, Polymer and countless other frameworks in any meaningful capacity. Please do let me know if any framework out there is conceptually different in how it models props.
I am not trying to bash the declarative style or any framework nor am I trying to promote any agenda or silver-bullet solution.

My goal here is to provide some food for thought and ideally learn from the feedback I get back.
I am using React in the examples below because I assume most readers are familiar with it.

Let's Talk Props

With all of that out of the way, let's have a look at how you would express that some UI component needs to be on the screen in a typical declarative manner. It would probably be something like:

<MyComponent prop1={val1} prop2={val2} ... />
Enter fullscreen mode Exit fullscreen mode

What is the contract from the point of view of whoever uses MyComponent? Just give it a bunch of mandatory/ optional props and it will present something that correlates to these props on the screen. To quote the React docs:

Conceptually, components are like JavaScript functions. They accept arbitrary inputs (called “props”) and return React elements describing what should appear on the screen.

Pretty straightforward, right? Not so fast...

Notice that what happens when/if you decide to change any of the props after the initial rendering is not a part of the contract.
Take a second to think about it...
"Conceptually, components are like JavaScript functions" they say, but to which extent are they really conceptually alike?

Is rendering MyComponent with prop1=5 and then changing prop1 to 3 equivalent to rendering MyComponent with prop1=3 in the first place? In other words, is MyComponent a pure function in regards to prop1? Is it a pure function in regards to prop2 (can be a different answer)? Can you tell by looking at this JSX/ template?

Have you ever wondered why writing pure functional components (read: the original ones, without hooks) in React feels so good? Here is your answer, or at least part of it:
The truth is that the only thing this kind of syntax can represent faithfully is a pure function (and even that is arguable).

What if MyComponent is a stateful/ side-effectful entity that exists over time and is not re-created on every prop change?
The syntax above tries to ignore this very real and very common possibility. It assumes purity.

Let's look at how this assumption breaks via a concrete example:

The initial value is passed into the child component as a prop and used as you would expect, to initialize the value :)
There is also a '+' button that allows you to increment the value after it was initialised.
Any subsequent change to the initial value prop (which you can make using the input box) has no effect over the actual value. It has already been initialized and the child component does not use it as part of its rendering logic. To be clear, from the child component's perspective this is the intended behaviour, not a bug.
React gives us no way of distinguishing between this kind of prop (in this case, some kind of initial setup) and the props that are used on every render. The props interface pretends there is no difference. It forces us to provide all of the values every time in a flat list.

Here is the code for this example:

import React, { useState } from "react";
import PropTypes from "prop-types";
import "./styles.css";

export default function App() {
  const [initialValue, setInitialValue] = useState();
  return (
    <div className="App">
      <h2>Configuration prop?</h2>
      <label htmlFor="init">Set initial value:</label>
      <input
        id="init"
        type="text"
        pattern="[0-9]*"
        value={initialValue || ""}
        onChange={e =>
          e.target.validity.valid
            ? setInitialValue(e.target.value)
            : initialValue
        }
      />
      <hr />
      {initialValue !== undefined && (
        <Configurable initialVal={parseInt(initialValue, 10)} />
      )}
    </div>
  );
}

class Configurable extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: props.initialVal };
  }
  render() {
    const { value } = this.state;
    return (
      <div>
        <h4>Configurable (class) component</h4>
        <span>Value: {value} </span>
        <button
          type="button"
          onClick={() => this.setState({ value: value + 1 })}
        >
          +
        </button>
      </div>
    );
  }
}

Configurable.propTypes = {
  initialVal: PropTypes.number.isRequired
};
Enter fullscreen mode Exit fullscreen mode

This might be a silly example but I encounter these kind of situations quite frequently in the real world. Think about passing-in a baseUrl that is used in componentDidMount (or useEffect/ useLayoutEffect with an empty dependencies array) in order to retrieve some assets; or how about some prop the developer wants to protect from changing after initialization - like session ID?
Are you tempted to tell me to stop complaining and just look at the documentation? If so, we agree that the code itself is not and cannot be expressive enough. What a strange thing...

Hooks make it even worse in this case. Let's see the same example implemented using a functional component instead of a class.

Here is the functional implementation of the Configurable component (App stays the same):

function Configurable({ initialVal }) {
  const [value, setValue] = useState(initialVal);
  return (
    <div>
      <h4>Configurable (functional) component</h4>
      <span>Value: {value} </span>
      <button type="button" onClick={() => setValue(v => v + 1)}>
        +
      </button>
    </div>
  );
}

Configurable.propTypes = {
  initialVal: PropTypes.number.isRequired
};
Enter fullscreen mode Exit fullscreen mode

Take a minute to think about how misleading this is. Even though a new initial value is directly passed-in to useState every time the prop changes, it is completely ignored (expected behaviour, I know, it is not the behaviour I am complaining about but the API design).
In the class implementation at least it was explicit; One look at the render function would make it clear that the initial-value prop is not involved.
Hooks try to pretend that everything can be expressed as rendering logic and in that sense add insult to injury.

Solution?

To be honest, I don't know what a good solution might be. It is tempting to think that separating the flat list of props into several smaller lists could be a step in the right direction. Something like:

<MyComponent initialization={prop1=val1, ...} rendering={prop2=val2, ...} ... />

Enter fullscreen mode Exit fullscreen mode

This might be better than nothing but it doesn't prevent me from changing the value of prop1 on the fly, which will be ignored.

In Imperative-land this issue doesn't exist. The imperative version would looks something like:

const myComponent = new MyComponent({'prop1': val1});
myComponent.attachTo(parentElement);
myComponent.render({'prop2': val2});
Enter fullscreen mode Exit fullscreen mode

For a non-pure component like ours, this is much more expressive and flexible, isn't it (and no I am not suggesting we switch back to JQuery)?

I have to ask: are props the best API we could come with? Do they deserve to be the standard?
Even an otherwise groundbreaking framework like Svelte doesn't seem to question them.
I wonder whether there is a better abstraction than props out there.
One that has semantics that are less detached from the underlying reality.
If you have an idea for one or are familiar with one, please do let me know.
Thanks for reading.

. . . . . . . . . . .