Problems with the main patterns of creating data-driven apps in React.js

For interface creation, React recommends using composition and state management libraries for building component hierarchies.
However, with complex patterns of composition problems emerge:

  1. Need to unnecessarily structure child elements
  2. Or pass them as props, which complicates readability, semantics and code structure

For the majority of developers, the problem may not be obvious, and they pass it over to the state management level. This is discussed in the React documentation:

If you only want to avoid passing some props through many levels, component composition is often a simpler solution than context.
Documentation of React. Context.

If we follow the link, we will see another point:

At Facebook, we use React in thousands of components, and we haven’t found any use cases where we would recommend creating component inheritance hierarchies.
Documentation of React. Composition vs. inheritance.

Of course, if everyone who uses a tool read the documentation and recognized the authority of the author, then this article wouldn’t exist. Therefore, let’s dissect the problems of existing approaches.

Pattern No. 1 – Directly Controlled Component

I started with this solution because of the Vue framework, which recommends such an approach. We take the data structure that comes from the backend or a form. Throw it into our component – for example, a movie card:

const MovieCard = (props) => {
 const {title, genre, description, rating, image} = props.data;
 return (
  <div>
   <img href={image} />
   <div><h2>{title}</h2></div>
   <div><p>{genre}</p></div>
   <div><p>{description}</p></div>
   <div><h1>{rating}</h1></div>
  </div>
 )
}

Stop. We already know about the endless expansion of requirements for components. What if suddenly the title has a link to the film review? And the genre – for the best films in it? Shouldn’t we now add:

const MovieCard = (props) => {
 const {title: {title},
  description: {description},
  rating: {rating},
  genre: {genre},
  image: {imageHref}
 } = props.data;

 return (
  <div>
   <img href={imageHref} />
   <div><h2>{title}</h2></div>
   <div><p>{genre}</p></div>
   <div><h1>{rating}</h1></div>
   <div><p>{description}</p></div>
  </div>
 )
}

This way we will protect ourselves from problems in the future, but open the door to the null pointer exception. Originally, we could pass the structure straight from the backend:

<MovieCard data={res.data} />

Now, we have to duplicate all the information every time:

<MovieCard data={{
 title: {res.title},
 description: {res.description},
 rating: {res.rating},
 image: {res.imageHref}
 }} />

However, we forgot about the genre and the component failed. And if you didn’t set fail guards, then the whole application failed with it.

Typescript comes to the rescue. We simplify the scheme by refactoring the card, and the elements that use it. Fortunately, everything is highlighted in the editor or during the build:

interface IMovieCardElement {
 text?: string;
}

interface IMovieCardImage {
 imageHref?: string;
}

interface IMovieCardProps {
 title: IMovieCardElement;
 description: IMovieCardElement;
 rating: IMovieCardElement;
 genre: IMovieCardElement;
 image: IMovieCardImage;
}
...
 const {title: {text: title},
  description: {text: description},
  rating: {text: rating},
  genre: {text: genre},
  image: {imageHref}
 } = props.data;

To save time, we still pass data through “as any” or “as IMovieCardProps”. So what happens? We already described one data structure three times (if we use it in one place). And what do we have? A component, that still can’t be modified. A component, that can potentially crash the entire application.

It’s time to reuse this component. Rating is no longer needed. We have two options:

Props withoutRating gets put everywhere we don’t need a rating:

const MovieCard = ({withoutRating, ...props}) => {
 const {title: {title},
  description: {description},
  rating: {rating},
  genre: {genre},
  image: {imageHref}
 } = props.data;
 return (
  <div>
   <img href={imageHref} />
   <div><h2>{name}</h2></div>
   <div><p>{genre}</p></div>
   { withoutRating &&
   <div><h1>{rating}</h1></div>
   }
   <div><p>{description}</p></div>
  </div>
 )
}

It’s fast, but we are pilling up props and building a fourth data structure.

Make rating in IMovieCardProps optional. Not forgetting to make it empty by default

const MovieCard = ({data, ...props}) => {
 const {title: {text: title},
  description: {text: description},
  rating: {text: rating} = {},
  genre: {text: genre},
  image: {imageHref}
 } = data;
 return (
  <div>
   <img href={imageHref} />
   <div><h2>{name}</h2></div>
   <div><p>{genre}</p></div>
   {
    data.rating &&
    <div><h1>{rating}</h1></div>
   }
   <div><p>{description}</p></div>
  </div>
 )
}

Clever, but it becomes more difficult to read the code. Again, we are repeating it for the fourth time. Control over the component is not obvious, since it’s opaquely controlled by the data structure. Let’s say we are asked to make the rating a link, but not everywhere:

rating: {text: rating, url: ratingUrl} = {},
  ...
   {
    data.rating &&
     data.rating.url ?
     <div>><h1><a href={ratingUrl}{rating}</a></h1></div>
     :
     <div><h1>{rating}</h1></div>
   }

And here we run into complicated logic, that is dictated by an opaque data structure.

Pattern #2 – Components with their own state and reducers

At the same time, both a strange and popular approach. I used it when I started working with React and functional JSX in Vue wasn’t enough. More than once I heard from developers at meetups that this approach allows you to pass more generalized data structures:

  1. Component can accept many data structures, the layout remains the same
  2. When receiving the data, it processes them according to the desired scenario
  3. The data is saved in the component so as not to start the reducer for every render (optional)

Naturally, to the problem of opacity (1) we add the problem of overloaded logic (2) and the addition of state in the final component (3).

The last one (3) is dictated by the internal safety of the object. That is, we check the depth of the object with loadsh.isEqual. If the case is advanced or JSON.stringify, it’s just the beginning. It’s also possible to add a timestamp and check it to see if everything is lost. The need to save or memoize falls away, since the optimization can be more complicated than a reducer due to the complexity of the calculation.

The data is passed through with the scenario name (usually a string):

<MovieCard data={{
 type: 'withoutRating',
 data: res.data,
}} />

Now, we write the component:

const MovieCard = ({data}) => {
 const card = reduceData(data.type, data.data);
 return (
  <div>
   <img href={card.imageHref} />
   <div><h2>{card.name}</h2></div>
   <div><p>{card.genre}</p></div>
   { card.withoutRating &&
   <div><h1>{card.rating}</h1></div>
   }
   <div><p>{card.description}</p></div>
  </div>
 )
}

And the logic:

const reduceData = (type, data) = {
 switch (type) {
  case 'withoutRating':
   return {
    title: {data.title},
    description: {data.description},
    rating: {data.rating},
    genre: {data.genre},
    image: {data.imageHref}
    withoutRating: true,
   };
  ...
 }
};

At this stage a few problems appear:

  1. By adding a layer of logic, we completely lose direct connection between the data and the display
  2. Duplication of logic for each case means that in a scenario where all the cards will need an age rating, it will need to be written in each reducer
  3. Other problems remain from pattern No. 1

Pattern No. 3 – Moving display logic and data into state management

Here we give up the data bus for building interfaces, which is React. We use a technology with its own logic model. This is probably the most common way to create applications in React, even though the manual warns that you shouldn’t use context in that way.

Use similar tools where React does not provide sufficient tools, in routing for example. Most likely you are using react-router. In this case, it would make sense to use context to pass the session to all the pages, instead of passing callbacks from the component of each top-level route. There is no separate abstraction for asynchronous actions in React other than what Javascript offers

Seems like there is a plus: we can reuse the logic in future versions of the application. But it’s a lie. On the one hand, it’s tied to the API, on the other, to the structure of the application, and the logic provides this connection. If you change one of the parts, it must be rewritten.

The Solution: Pattern No. 4 – Composition

The method of composition is obvious if you follow the following principles (not counting the similar approach in the book Design
Patterns):

  1. Frontend development – development of user interfaces – uses HTML language for layout
  2. Javascript is used to receive, transmit and process data

Therefore, transfer data from one domain to another as early as possible. React uses JSX abstraction to template HTML – but in practice a set of createElement methods are used. That is, JSX and React components, which are also JSX elements, should be treated as a method of display and behavior, rather than transformation and data processing, which should occur at a separate level.

At this step, many use the methods listed above, but they do not solve the key problem of expanding and modifying the display of components. How it’s done according to the creators of the library is shown in the documentation:

function SplitPane(props) {
  return (
    <div className="SplitPane">
      <div className="SplitPane-left">
        {props.left}
      </div>
      <div className="SplitPane-right">
        {props.right}
      </div>
    </div>
  );
}

function App() {
  return (
    <SplitPane
      left={
        <Contacts />
      }
      right={
        <Chat />
      } />
  );
}

That is, as parameters, instead of strings, numbers and Boolean types, ready-made components are passed.

Unfortunately, this method also proved to be inflexible. Here’s why:

  1. Both props are mandatory. This limits reuse of the component
  2. Optionality would mean overloading the SplitPane component with logic
  3. Nesting and multiplicity are not displayed very semantically
  4. This display logic would have to be re-written for each component that accepts props

As a result, such a solution can grow in complexity even for fairly simple scenarios:

function SplitPane(props) {
  return (
    <div className="SplitPane">
      {
      props.left &&
      <div className="SplitPane-left">
        {props.left}
      </div>
     }
     {
      props.right &&
       <div className="SplitPane-right">
        {props.right}
       </div>
     }
    </div>
  );
}

function App() {
  return (
    <SplitPane
      left={
       contacts.map(el =>
        <Contacts
           name={
             <ContactsName name={el.name} />
           }
           phone={
             <ContactsPhone name={el.phone} />
           }
         />
        )
      }
      right={
        <Chat />
      } />
  );
}

In the documentation code like this, in the case of higher order components (HOC) and render props, is called “wrapper hell”. Readability becomes more difficult with each addition of a new element.

One answer to this problem – slots – is present in Web Components technology and the Vue framework. However, in both cases,
there are limitations: first, the slots are defined as a string rather than a character, which makes refactoring more difficult. Secondly, slots are limited in functionality and cannot control their own display, pass other slots to their child components, or be reused in other elements.

In short, something like this, let’s call it Pattern No. 5 – Slots:

function App() {
  return (
    <SplitPane>
      <LeftPane>
        <Contacts />
      </LeftPane>
      <RightPane>
        <Chat />
      </RightPane>
    </SplitPane>
  );
}

I will talk about this in the next article about existing slot pattern solutions in React and my own solution.


Written by Kirill Vysokolov, translated from here