React Best Practices





Are you developing with React or just interested in this technology? Then welcome to my new project - Total React .



Introduction



I have been working with React for 5 years, however, when it comes to the structure of the application or its appearance (design), it is difficult to name any universal approaches.



At the same time, there are certain coding techniques that allow you to ensure long-term support and scalability of your projects.



This article is kind of a set of rules for developing React applications that have proven to be effective for me and the teams I have worked with.



These rules cover components, application structure, testing, styling, state management, and data retrieval. The examples are intentionally simplified to emphasize general principles rather than specific implementation.



The proposed approaches are not the ultimate truth. This is just my opinion. There are many different ways to accomplish the same task.



Components



Functional components


Give preference to functional components - they have simpler syntax. They lack lifecycle methods, constructors, and boilerplate code. They allow you to implement the same logic as class components, but with less effort and in a more descriptive way (component code is easier to read).



Use functional components until you need fuses. The mental model to keep in mind will be much simpler.



//     ""
class Counter extends React.Component {
  state = {
    counter: 0,
  }

  constructor(props) {
    super(props)
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() {
    this.setState({ counter: this.state.counter + 1 })
  }

  render() {
    return (
      <div>
        <p> : {this.state.counter}</p>
        <button onClick={this.handleClick}></button>
      </div>
    )
  }
}

//       
function Counter() {
  const [counter, setCounter] = useState(0)

  handleClick = () => setCounter(counter + 1)

  return (
    <div>
      <p> : {counter}</p>
      <button onClick={handleClick}></button>
    </div>
  )
}

      
      





Consistent (sequential) components


Stick to the same style when creating components. Place helper functions in the same place, use the same export (by default or by name) and use the same naming convention for components.



Each approach has its own advantages and disadvantages.



It doesn't matter how you export the components, at the very bottom or in the definition, just stick to one rule.



Component names


Always name the components. This helps in parsing the error stack trace when using React developer tools.



It also helps you determine which component you are currently developing.



//    
export default () => <form>...</form>

//    
export default function Form() {
  return <form>...</form>
}

      
      





Secondary functions


Functions that do not require the data stored in the component must be outside (outside) the component. The ideal place for this is before the component definition, so that the code can be examined from top to bottom.



This reduces the "noise" of the component - only the essentials are left in it.



//    
function Component({ date }) {
  function parseDate(rawDate) {
    ...
  }

  return <div> {parseDate(date)}</div>
}

//      
function parseDate(date) {
  ...
}

function Component({ date }) {
  return <div> {parseDate(date)}</div>
}

      
      





There should be a minimum number of auxiliary functions inside the component. Place them outside, passing values โ€‹โ€‹from the state as arguments.



By following the rules for creating "clean" functions, it is easier to track errors and extend the component.



//      ""    
export default function Component() {
  const [value, setValue] = useState('')

  function isValid() {
    // ...
  }

  return (
    <>
      <input
        value={value}
        onChange={e => setValue(e.target.value)}
        onBlur={validateInput}
      />
      <button
        onClick={() => {
          if (isValid) {
            // ...
          }
        }}
      >
        
      </button>
    </>
  )
}

//          
function isValid(value) {
  // ...
}

export default function Component() {
  const [value, setValue] = useState('')

  return (
    <>
      <input
        value={value}
        onChange={e => setValue(e.target.value)}
        onBlur={validateInput}
      />
      <button
        onClick={() => {
          if (isValid(value)) {
            // ...
          }
        }}
      >
        
      </button>
    </>
  )
}

      
      





Static (hard) markup


Don't create static markup for navigation, filters, or lists. Instead, create an object with settings and loop over it.



This means that you only need to change the markup and elements in one place, if necessary.



//     
function Filters({ onFilterClick }) {
  return (
    <>
      <p> </p>
      <ul>
        <li>
          <div onClick={() => onFilterClick('fiction')}> </div>
        </li>
        <li>
          <div onClick={() => onFilterClick('classics')}>
            
          </div>
        </li>
        <li>
          <div onClick={() => onFilterClick('fantasy')}></div>
        </li>
        <li>
          <div onClick={() => onFilterClick('romance')}></div>
        </li>
      </ul>
    </>
  )
}

//       
const GENRES = [
  {
    identifier: 'fiction',
    name: ' ',
  },
  {
    identifier: 'classics',
    name: '',
  },
  {
    identifier: 'fantasy',
    name: '',
  },
  {
    identifier: 'romance',
    name: '',
  },
]

function Filters({ onFilterClick }) {
  return (
    <>
      <p> </p>
      <ul>
        {GENRES.map(genre => (
          <li>
            <div onClick={() => onFilterClick(genre.identifier)}>
              {genre.name}
            </div>
          </li>
        ))}
      </ul>
    </>
  )
}

      
      





Component dimensions


A component is just a function that takes props and returns markup. They follow the same design principles.



If a function performs too many tasks, move some of the logic out to another function. The same is true for components - if a component contains too complex functionality, divide it into several components.



If part of the markup is complex, includes loops or conditions, extract it into a separate component.



Rely on props and callbacks for interaction and data retrieval. The number of lines of code is not always an objective criterion for its quality. Always remember to be responsive and abstracted.



Comments in JSX


If you need an explanation of what is happening, create a comment block and add the necessary information there. Markup is part of the logic, so if you feel like you need to comment on a part, do so.



function Component(props) {
  return (
    <>
      {/*    ,       */}
      {user.subscribed ? null : <SubscriptionPlans />}
    </>
  )
}

      
      





Circuit breakers


An error in the component should not break the user interface. There are rare cases where we want a critical error to result in an application crash or redirect. In most cases, it is enough to remove a certain element from the screen.



In a function requesting data, we can have any number of try / catch blocks. Use fuses not only at the top level of your application, but wrap around every component that could potentially throw an exception to avoid a cascade of errors.



function Component() {
  return (
    <Layout>
      <ErrorBoundary>
        <CardWidget />
      </ErrorBoundary>

      <ErrorBoundary>
        <FiltersWidget />
      </ErrorBoundary>

      <div>
        <ErrorBoundary>
          <ProductList />
        </ErrorBoundary>
      </div>
    </Layout>
  )
}

      
      





Destructuring props


Most of the components are functions that take props and return markup. In a normal function, we use arguments passed directly to it, so it makes sense to follow a similar approach in the case of components. There is no need to repeat "props" everywhere.



The reason for not using destructuring might be the difference between the external and internal states. However, in a normal function, there is no difference between arguments and variables. You don't need to complicate things.



//    "props"   
function Input(props) {
  return <input value={props.value} onChange={props.onChange} />
}

//        
function Component({ value, onChange }) {
  const [state, setState] = useState('')

  return <div>...</div>
}

      
      





Number of props


The answer to the question about the number of props is very subjective. The number of props passed to a component is correlated with the number of variables used by the component. The more props are passed to the component, the higher its responsibility (meaning the number of tasks solved by the component).



A large number of props may indicate that the component is doing too much.



If more than 5 props are passed to a component, I think about the need to split it. In some cases, the component just needs a lot of data. For example, a text entry field may need a lot of props. On the other hand, this is a sure sign that some of the logic needs to be extracted into a separate component.



Please note: the more props a component receives, the more often it is redrawn.



Passing an object instead of primitives


One way to reduce the number of props passed is to pass an object instead of primitives. Instead of, for example, transmitting the user's name, their email address, etc. one at a time, you can group them. It will also make it easier to add new data.



//      
<UserProfile
  bio={user.bio}
  name={user.name}
  email={user.email}
  subscription={user.subscription}
/>

//   ,  
<UserProfile user={user} />

      
      





Conditional rendering


In some cases, using short computations (the logical AND operator &&) for conditional rendering may result in 0 being displayed in the UI. To avoid this use the ternary operator. The only downside to this approach is a little more code.



The && operator reduces the amount of code, which is great. Ternarnik is more "verbose", but it always works correctly. In addition, it becomes less time consuming to add an alternative as needed.



//     
function Component() {
  const count = 0

  return <div>{count && <h1>: {count}</h1>}</div>
}

//   ,   
function Component() {
  const count = 0

  return <div>{count ? <h1>: {count}</h1> : null}</div>
}

      
      





Nested ternary operators


Ternary operators become difficult to read after the first nesting level. Although ternaries are space-saving, it is best to express their intentions in an explicit and obvious way.



//     
isSubscribed ? (
  <ArticleRecommendations />
) : isRegistered ? (
  <SubscribeCallToAction />
) : (
  <RegisterCallToAction />
)

//      
function CallToActionWidget({ subscribed, registered }) {
  if (subscribed) {
    return <ArticleRecommendations />
  }

  if (registered) {
    return <SubscribeCallToAction />
  }

  return <RegisterCallToAction />
}

function Component() {
  return (
    <CallToActionWidget
      subscribed={subscribed}
      registered={registered}
    />
  )
}

      
      





Lists


Looping through the elements of a list is a common task, usually accomplished with the "map ()" method. However, in a component that contains a lot of markup, the extra indentation and the "map ()" syntax do not improve readability.



If you need to iterate over the elements, extract them into a separate component, even if the markup is small. The parent component does not need details, it only needs to "know" that the list is being rendered in a certain place.



The iteration can be left in a component whose sole purpose is to display the list. If the list markup is complex and long, it is best to extract it into a separate component.



//       
function Component({ topic, page, articles, onNextPage }) {
  return (
    <div>
      <h1>{topic}</h1>
      {articles.map(article => (
        <div>
          <h3>{article.title}</h3>
          <p>{article.teaser}</p>
          <img src={article.image} />
        </div>
      ))}
      <div>    {page}</div>
      <button onClick={onNextPage}></button>
    </div>
  )
}

//      
function Component({ topic, page, articles, onNextPage }) {
  return (
    <div>
      <h1>{topic}</h1>
      <ArticlesList articles={articles} />
      <div>    {page}</div>
      <button onClick={onNextPage}></button>
    </div>
  )
}

      
      





Default props


One way to define default props is to add a "defaultProps" attribute to the component. However, with this approach, the component function and the values โ€‹โ€‹for its arguments will be in different places.



Therefore, it is more preferable to assign "default" values โ€‹โ€‹when destructuring props. This makes the code easier to read from top to bottom and keeps definitions and values โ€‹โ€‹in one place.



//          
function Component({ title, tags, subscribed }) {
  return <div>...</div>
}

Component.defaultProps = {
  title: '',
  tags: [],
  subscribed: false,
}

//      
function Component({ title = '', tags = [], subscribed = false }) {
  return <div>...</div>
}

      
      





Nested render functions


If you need to extract logic or markup from a component, do not put it in a function in the same component. A component is a function. This means that the extracted part of the code will be represented as a nested function.



This means that the nested function will have access to the state and data of the outer function. This makes the code less readable - what does this function do (what is it responsible for)?



Move the nested function into a separate component, give it a name, and rely on props instead of closures.



//       
function Component() {
  function renderHeader() {
    return <header>...</header>
  }
  return <div>{renderHeader()}</div>
}

//      
import Header from '@modules/common/components/Header'

function Component() {
  return (
    <div>
      <Header />
    </div>
  )
}

      
      





State management



Gearboxes


Sometimes we need a more powerful way to define and manage state than "useState ()". Try using "useReducer ()" before using third party libraries. It is a great tool for managing complex state without requiring dependencies.



Combined with context and TypeScript, useReducer () can be very powerful. Unfortunately, it is not used very often. People prefer to use special libraries.



If you need multiple pieces of state, move them to the reducer:



//       
const TYPES = {
  SMALL: 'small',
  MEDIUM: 'medium',
  LARGE: 'large'
}

function Component() {
  const [isOpen, setIsOpen] = useState(false)
  const [type, setType] = useState(TYPES.LARGE)
  const [phone, setPhone] = useState('')
  const [email, setEmail] = useState('')
  const [error, setError] = useSatte(null)

  return (
    // ...
  )
}

//      
const TYPES = {
  SMALL: 'small',
  MEDIUM: 'medium',
  LARGE: 'large'
}

const initialState = {
  isOpen: false,
  type: TYPES.LARGE,
  phone: '',
  email: '',
  error: null
}

const reducer = (state, action) => {
  switch (action.type) {
    ...
    default:
      return state
  }
}

function Component() {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    // ...
  )
}

      
      





Hooks versus HOCs and render props


In some cases, we need to "harden" a component or provide it with access to external data. There are three ways to do this - higher order components (HOCs), rendering via props and hooks.



The most effective way is to use hooks. They fully comply with the philosophy that a component is a function that uses other functions. Hooks allow you to access multiple sources containing external functionality without the threat of a conflict between these sources. The number of hooks does not matter, we always know where we got the value from.



HOCs receive values โ€‹โ€‹as props. It is not always obvious where the values โ€‹โ€‹come from, from the parent component or from its wrapper. In addition, chaining multiple props is a well-known source of bugs.



Using render props leads to deep nesting and poor readability. Placing multiple components with render props in the same tree further exacerbates the situation. In addition, they only use values โ€‹โ€‹in the markup, so the logic for getting the values โ€‹โ€‹has to be written here or received from outside.



In the case of hooks, we're working with simple values โ€‹โ€‹that are easy to track and don't mix with JSX.



//    -
function Component() {
  return (
    <>
      <Header />
        <Form>
          {({ values, setValue }) => (
            <input
              value={values.name}
              onChange={e => setValue('name', e.target.value)}
            />
            <input
              value={values.password}
              onChange={e => setValue('password', e.target.value)}
            />
          )}
        </Form>
      <Footer />
    </>
  )
}

//   
function Component() {
  const [values, setValue] = useForm()

  return (
    <>
      <Header />
        <input
          value={values.name}
          onChange={e => setValue('name', e.target.value)}
        />
        <input
          value={values.password}
          onChange={e => setValue('password', e.target.value)}
        />
      <Footer />
    </>
  )
}

      
      





Libraries for getting data


Very often the data for the state "comes" from the API. We need to store them in memory, update and receive them in several places.



Modern libraries like React Query provide a fair amount of tools for manipulating external data. We can cache data, delete it and request new ones. These tools can also be used to send data, trigger an update of another piece of data, etc.



Working with external data is even easier if you use a GraphQL client like Apollo . It implements the concept of client state out of the box.



State management libraries


In the vast majority of cases, we don't need any libraries to manage application state. They are only required in very large applications with very complex state. In such situations, I use one of two solutions - Recoil or Redux .



Component mental models



Container and Representative


Usually, it is customary to divide components into two groups - representatives and containers or "smart" and "stupid".



The bottom line is that some components do not contain state and functionality. They are just called by the parent component with some props. The container component, in turn, contains some business logic, sends requests to receive data, and manages state.



This mental model actually describes the MVC design pattern for server-side applications. She works great there.



But in modern client applications, this approach does not justify itself. Putting all the logic in multiple components leads to over-bloating. This leads to the fact that one component solves too many problems. The code for such a component is difficult to maintain. As the application grows, maintaining the code in a proper state becomes almost impossible.



Stateful and stateless components


Divide components into stateful and stateless components. The mental model mentioned above suggests that a small number of components must drive the logic of the entire application. This model assumes the division of logic into the maximum possible number of components.



The data should be as close as possible to the component in which it is used. When using the GrapQL client, we receive data in a component that displays this data. Even if it's not a top-level component. Don't think about containers, think about component responsibility. Determine the most appropriate component to hold a portion of the state.



For example, a <Form /> component must contain form data. The <Input /> component must receive values โ€‹โ€‹and call callbacks. The <Button /> component should notify the form about the user's desire to send data for processing, etc.



Who is responsible for validating the form? Input field? This will mean that this component is responsible for the business logic of the application. How will it inform the form about an error? How will the error state be updated? Will the form "know" about such an update? If an error occurs, will it be possible to send the data for processing?



When such questions arise, it becomes apparent that there is a confusion of responsibilities. In this case, the "input" is better to remain a stateless component and receive error messages from the form.



Application structure



Grouping by route / module


The grouping by containers and components makes the application difficult to learn. Determining which part of an application a particular component belongs to assumes a "close" familiarity with the entire codebase.



Not all components are the same - some are used globally, others are designed to meet specific needs. This structure is suitable for small projects. However, for medium to large projects such a structure is unacceptable.



//        
โ”œโ”€โ”€ containers
|   โ”œโ”€โ”€ Dashboard.jsx
|   โ”œโ”€โ”€ Details.jsx
โ”œโ”€โ”€ components
|   โ”œโ”€โ”€ Table.jsx
|   โ”œโ”€โ”€ Form.jsx
|   โ”œโ”€โ”€ Button.jsx
|   โ”œโ”€โ”€ Input.jsx
|   โ”œโ”€โ”€ Sidebar.jsx
|   โ”œโ”€โ”€ ItemCard.jsx

//     /
โ”œโ”€โ”€ modules
|   โ”œโ”€โ”€ common
|   |   โ”œโ”€โ”€ components
|   |   |   โ”œโ”€โ”€ Button.jsx
|   |   |   โ”œโ”€โ”€ Input.jsx
|   โ”œโ”€โ”€ dashboard
|   |   โ”œโ”€โ”€ components
|   |   |   โ”œโ”€โ”€ Table.jsx
|   |   |   โ”œโ”€โ”€ Sidebar.jsx
|   โ”œโ”€โ”€ details
|   |   โ”œโ”€โ”€ components
|   |   |   โ”œโ”€โ”€ Form.jsx
|   |   |   โ”œโ”€โ”€ ItemCard.jsx

      
      





From the beginning, group the components by route / module. This structure allows long-term support and expansion. This will prevent the application from outgrowing its architecture. If you rely on the "container-component architecture", then this will happen very quickly.



The module-based architecture is highly scalable. You simply add new modules without increasing the complexity of the system.



The "container architecture" is not wrong, but it is not very general (abstract). It won't tell anyone who learns it other than that it uses React to develop the application.



Common modules


Components such as buttons, input fields, and cards are ubiquitous. Even if you are not using a component based framework, extract them into shared components.



This way, you can see what common components are used in your application, even without the help of a Storybook . This avoids duplication of code. You don't want every member of your team to design their own version of the button, do you? Unfortunately, this is often due to poor application architecture.



Absolute paths


Individual parts of the application should be changed as easily as possible. This applies not only to the component code, but also to its location. Absolute paths mean that you don't have to change anything when you move the imported component to a different location. It also makes it easier to locate the component.



//     
import Input from '../../../modules/common/components/Input'

//      
import Input from '@modules/common/components/Input'

      
      





I use the "@" prefix as an indicator of the inner module, but I've also seen examples of using the "~" character.



Wrapping external components


Try not to import too many third-party components directly. By creating an adapter for such components, we can modify their API if necessary. We can also change the libraries used in one place.



This applies to both component libraries like the Semantic UI and utilities. The simplest way is to re-export such components from the shared module.



The component doesn't need to know which particular library we are using.



//     
import { Button } from 'semantic-ui-react'
import DatePicker from 'react-datepicker'

//       
import { Button, DatePicker } from '@modules/common/components'

      
      





One component - one directory


I create a component directory for each module in my applications. First, I create a component. Then, if there is a need for additional files related to the component, such as styles or tests, I create a directory for the component and place all files in it.



It is good practice to create an "index.js" file to re-export the component. This allows you not to change the import paths and avoid duplication of the component name - "import Form from 'components / UserForm / UserForm'". However, you should not put the component code in the "index.js" file, as this will make it impossible to find the component by the name of the tab in the code editor.



//        
โ”œโ”€โ”€ components
    โ”œโ”€โ”€ Header.jsx
    โ”œโ”€โ”€ Header.scss
    โ”œโ”€โ”€ Header.test.jsx
    โ”œโ”€โ”€ Footer.jsx
    โ”œโ”€โ”€ Footer.scss
    โ”œโ”€โ”€ Footer.test.jsx

//      
โ”œโ”€โ”€ components
    โ”œโ”€โ”€ Header
        โ”œโ”€โ”€ index.js
        โ”œโ”€โ”€ Header.jsx
        โ”œโ”€โ”€ Header.scss
        โ”œโ”€โ”€ Header.test.jsx
    โ”œโ”€โ”€ Footer
        โ”œโ”€โ”€ index.js
        โ”œโ”€โ”€ Footer.jsx
        โ”œโ”€โ”€ Footer.scss
        โ”œโ”€โ”€ Footer.test.jsx

      
      





Performance



Premature optimization


Before starting optimization, make sure there are reasons for this. Blindly following best practices is a waste of time if it has no impact on the application.



Of course, you need to think about things like optimization, but your preference should be to develop readable and maintainable components. Well-written code is easier to improve.



If you notice a performance issue in your application, measure it and determine the cause. It makes no sense to reduce the number of re-renders with a huge bundle size.



After identifying the problems, fix them in order of performance impact.



Build size


The amount of JavaScript sent to the browser is a key factor in application performance. The application itself can be very fast, but no one will know about it if you have to preload 4 MB of JavaScript to run it.



Don't aim for one bundle. Split your application at the route level and more. Make sure to send the minimum amount of code to the browser.



Load in the background or when the user intends to get another part of the application. If clicking a button starts a PDF download, you can start downloading the corresponding library the moment you hover over the button.



Re-rendering - callbacks, arrays and objects


You should strive to reduce the number of component re-renders. Keep this in mind, but keep in mind that unnecessary re-renders rarely have a significant impact on the application.



Do not send callbacks as props. With this approach, the function is re-created each time, triggering re-rendering.



If you run into performance problems caused by closures, get rid of them. But don't make your code less readable or too verbose.



Passing arrays or objects explicitly falls into the same category of problems. They are compared by reference, so they don't pass a superficial check and trigger a re-render. If you need to pass in a static array, create it as a constant before defining the component. This will allow the same instance to be passed every time.



Testing



Snapshot testing


Once, I ran into an interesting problem while doing snapshot testing: comparing โ€œnew Date ()โ€ without an argument to the current date always returned โ€œfalseโ€.



In addition, snapshots only result in failed assemblies when the component changes. A typical workflow is as follows: make changes to the component, fail the test, update the snapshot, and continue.



It is important to understand that snapshots do not replace component-level tests. Personally, I no longer use this type of testing.



Testing Correct Rendering


The main purpose of testing is to confirm that the component is performing as expected. Make sure the component returns correct markup with both default and passed props.



Also, make sure that the function always returns correct results for specific inputs. Check that everything you need is displayed correctly on the screen.



Testing state and events


A stateful component usually changes in response to an event. Create an event simulation and check that the component responds correctly to it.



Make sure handlers are called and the correct arguments are passed. Check if the internal state is set correctly.



Testing edge cases


After covering the code with basic tests, add some tests to check for special cases.



This could mean passing in an empty array to make sure the index is not accessed without checking. It could also mean calling an error in a component (for example, in an API request) to check if it was handled correctly.



Integration testing


Integration testing means testing an entire page or a large component. This type of testing means testing the performance of a certain abstraction. It provides a more convincing result that the application is performing as expected.



Individual components can pass unit tests successfully, but interactions between them can cause problems.



Stylization



CSS-to-JS


This is a very controversial issue. I personally prefer to use libraries like Styled Components or Emotion, which allow you to write styles in JavaScript. One file less. Don't think about things like class names.



The building block of React is a component, so the CSS-in-JS technique, or more accurately, all-in-JS, is in my opinion the preferred technique.



Please note that other styling approaches (SCSS, CSS modules, libraries with styles like Tailwind) are not wrong, but I still recommend using CSS-in-JS.



Stylized components


Usually, I try to keep styled components and the component that uses them in the same file.



However, when there are a lot of styled components, it makes sense to move them into a separate file. I've seen this approach used in some open source projects like Spectrum.



Receiving data



Libraries for working with data


React doesn't provide any special tools for getting or updating data. Each team creates its own implementation, usually including a service for asynchronous functions that interact with the API.



Taking this approach means that it is our responsibility to track the download status and handle HTTP errors. This leads to verboseness and a lot of boilerplate code.



Instead, it's better to use libraries like React Query and SWR . They make server interactions an organic part of the component lifecycle in an idiomatic way - using hooks.



They have built-in support for caching, load state management, and error handling. They also eliminate the need for state management libraries to handle this data.



Thank you for your attention and a good start to the working week.



All Articles