DEV Community

Viljami Kuosmanen for epilot

Posted on

Surprising Performance Lessons from React Microfrontends in Production

The epilot engineering team stands at 27 developers 1 year after the launch of our rewritten portal built on mostly* React microfrontends.

Microfrontends screenshot

*Part of our app is written using other frontend frameworks, most notably the sidebar navigation written in Svelte.

Since the initial launch a year ago, our teams have gained a lot of experience running React microfrontends in production using single-spa.

While we expected to face challenges with our new frontend microservices architecture, after solving a few initial problems we haven't hit any major snags with single-spa in the first year.

To my surprise, most issues that have emerged in our codebase are general React pain points not specific to microfrontend architecture at all.

In an effort to share knowledge, I'll address the most common React performance issue we've seen reemerge in our teams in this post.

The state management problem

Here's a really common hook pattern I've seen emerge at one point in most of our React microfrontend projects:

// useFormState.jsx
import React from 'react'

const FormContext = React.createContext()

export const GlobalFormStateProvider = (props) => {
  const [formState, setFormState] = React.useState({})

  return (
    <FormContext.Provider value={{ formState, setFormState }}>
      {props.children}
    </FormContext.Provider>
  )
}

export const useFormState = () => React.useContext(FormContext)
Enter fullscreen mode Exit fullscreen mode
// App.jsx
import { GlobalFormStateProvider } from './useFormState'
import { Form } from './Form' 

export const App = () => (
  <GlobalFormStateProvider>
    <Form />
  </GlobalFormStateProvider>
}
Enter fullscreen mode Exit fullscreen mode
// Form.jsx
import React from 'react'
import { useFormState } from './useFormState'
import { api } from './api'

export const Form = () => (
  const { formState } = useFormState() 

  const handleSubmit = React.useCallback(
    () => api.post('/v1/submit', formState),
    [formState]
  )

  return (
    <form onSubmit={handleSubmit}>
      <FirstFormGroup />
      <SecondFormGroup />
    </form>
  )
)

const FirstFormGroup = () => (
  const { formState, setFormState } = useFormState()

  return (
    <div className="form-group">
      <input
        value={formState.field1}
        onChange={(e) => 
          setFormState({ ...formState, field1: e.target.value })}
      />
      <input
        value={formState.field2}
        onChange={(e) => 
          setFormState({ ...formState, field2: e.target.value })}
      />
    </div>
  )
)

const SecondFormGroup = () => (
  const { formState, setFormState } = useFormState()

   return (
    <div className="form-group">
      <input
        value={formState.field3}
        onChange={(e) => 
          setFormState({ ...formState, field3: e.target.value })}
      />
    </div>
  )
)
Enter fullscreen mode Exit fullscreen mode

Many readers will immediately recognize antipatterns in the above example, but entertain the naΓ―ve perspective:

The useFormState() hook is very useful. No prop drilling. No fancy global state management libraries needed. Just native React.useState() shared in a global Context.

What's not to love here?

Perf issues

As nice as useFormState() seems, we'd quickly face performance issues due to components using it having to render on every setFormState() causing unnecessary, potentially expensive re-renders.

This is because we've subscribed all our Form components to re-render on all changes in FormContext by using React.useContext(FormContext) inside useFormState().

You might think React.memo to the rescue, but reading the React docs:

When the nearest above the component updates, this Hook will trigger a re-render with the latest context value passed to that MyContext provider. Even if an ancestor uses React.memo or shouldComponentUpdate, a re-render will still happen starting at the component itself using useContext.

Further, we're unnecessarily depending on the full formState object in all our form components.

Consider:

// formState is a dependency:
setFormState({ ...formState, field1: e.target.value })}
// formState not a dependency:
setFormState((formState) => ({ ...formState, field1: e.target.value }))
Enter fullscreen mode Exit fullscreen mode

At this time, I would consider Context Providers using React.useState to store complex global app state a general React performance antipattern.

However, if React adds useContextSelector (RFC) I am positive the situation could change. 🀞

Lessons learned

Seeing antipatterns like these emerge in React projects even with fairly experienced frontend developers (think 5+ years of React) has lead me to consider performance as a topic that unfortunately requires pretty significant investment to produce quality output when working with React in general.

As always, there is No Silver Bullet. However, our frontend microservices architecture has enabled us to cheaply experiment with different approaches in different teams who have produced quite a few competing strategies to solve form performance:

  • Use of global state management libraries e.g. Redux, MobX and XState.
  • Use of dedicated form libraries e.g. react-hook-form
  • Use of this implementation of useContextSelector
  • Avoiding controlled form inputs (Leverage the web platform! πŸ‘)

Additionally, thanks to the flexibility of single-spa we've been able to experiment outside of the React ecosystem with frameworks like Svelte and others which has been extremely promising and rewarding for our engineers.

epilot logo

We're hiring @ epilot!

Discussion (0)