Problems of the basic patterns for creating data-driven apps on React.JS

To create interfaces, React recommends using composition and state management libraries to build component hierarchies. However, with complex composition patterns, problems appear:







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


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







If you want to get rid of passing some props down to many levels, usually component composition is a simpler solution than context.

React documentation Context.

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







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

React documentation Composition versus inheritance.

Of course, if everyone who uses the tool read the documentation and recognize the authority of the authors, then this publication would not have been. Therefore, we analyze the problems of existing approaches.







Pattern # 1 - Directly Controlled Components









I started with this solution because of the Vue framework that recommends this approach . We take the data structure that comes from the backing or design in the case of the form. Forward to our component - for example, to 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 component requirements. Suddenly the title will have a link to the movie review? And the genre - for the best films from it? Do not add now:







 const MovieCard = (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> <div><h1>{rating}</h1></div> <div><p>{description}</p></div> </div> ) }
      
      





So we will protect ourselves from problems in the future, but open the door to a zero point error. Initially, we could throw structures directly from the back:







 <MovieCard data={res.data} />
      
      





Now every time you need to duplicate all the information:







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





However, we forgot about the genre - and the component fell. And if you did not put error limiters, then the whole application is 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 assembly:







 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’ll still roll the data “as any” or “as IMovieCardProps”. What turns out? We have already described three times (if used in one place) one data structure. And what do we have? A component that still cannot be modified. A component that could potentially crash the entire application.







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







Put prop withoutRating wherever rating is needed







 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> ) }
      
      





Fast, but we pile up props and build a fourth data structure.







Making rating in IMovieCardProps optional. Do not forget to make it an empty object 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> ) }
      
      





Trickier, but it becomes difficult to read the code. Again, repeat for the fourth time . Control over the component is not obvious, as it is opaquely controlled by the data structure. Let's say we were asked to make the notorious 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 then we run into the complex logic that dictates the opaque data structure.







Pattern No. 2 - Components with their own state and reducers









At the same time, a strange and popular approach. I used it when I started working with React and the JSX functionality in Vue was lacking. I have often heard from developers at mitaps that this approach allows you to skip more generalized data structures:







  1. A component can take many data structures; layout remains the same
  2. When receiving data, it processes them according to the desired scenario.
  3. The data is stored in the state of the component so as not to start the reducer with each render (optional)


Naturally, the problem of opacity (1) is supplemented by the problem of logic overload (2) and the addition of state to the final component (3).







The last (3) is dictated by the internal safety of the object. That is, we deeply check objects through lodash.isEqual. If the event is advanced or JSON.stringify, everything is just beginning. You can also add a timestamp and check for it if everything is lost. There is no need to save or memoize, since the optimization can be more complicated than a reducer due to the complexity of the calculation.







Data is thrown with the name of the script (usually a string):







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





Now 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, }; ... } };
      
      





There are several problems in this step:







  1. By adding a layer of logic, we finally lose the direct connection between data and the display
  2. Duplication of logic for each case means that in the case when all cards need an age rating, it will need to be registered in each reducer
  3. Remaining Other Problems from Step # 1


Pattern # 3 - Transferring Display Logic and Data to State Management









Here we abandon the data bus for building interfaces, which is React. We use technology with our own logic model. This is probably the most common way to build React applications, although the manual warns you should not use context in this way.







Use similar tools where React does not provide sufficient tools - for example, in routing. Most likely you are using react-router. In this case, using the context rather than forwarding the callback from the component of each top-level route would make more sense to forward the session to all pages. React does not have a separate abstraction for asynchronous actions other than the one that Javascript offers.







It would seem that there is a plus: we can reuse the logic in future versions of the application. But this is a hoax. On the one hand, it is tied to the API, on the other, to the structure of the application, and the logic provides this connection. When changing one of the parts, it needs to be rewritten.







Solution: Pattern No. 4 - composition









The composition method is obvious if you follow the following principles (apart from a similar approach in Design Patterns ):







  1. Frontend development - development of user interfaces - uses HTML 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 fact it uses a set of createElement methods. 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 processing of data that must 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 to do this, 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, made-up components are transferred.







Alas, this method also turned out to be inflexible. That's why:







  1. Both props are required. This limits component reuse
  2. Optional would mean overloading the SplitPane component with logic.
  3. Nesting and plurality are not displayed very semantically.
  4. This mapping logic would have to be rewritten 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, a similar code, in the case of higher-order components (HOC) and render props, is called a wrapper hell”. With each addition of a new element, the readability of the code becomes more difficult.







One answer to this problem - slots - is present in Web component technology and in the Vue framework . In both places, however, there are restrictions: firstly, the slots are not identified by a symbol, but by a string, which complicates refactoring. Secondly, slots are limited in functionality and cannot control their own display, transfer other slots to 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’ll talk about this in the next article about the existing solutions for the slot pattern in React and about my own solution.








All Articles