You may not need useState as often

March 20, 2023

useState is one of the most used hooks when we write React applications. However, maybe you don't need it as much as you think. In this article, we'll look at situations where you can use other methods instead of useState.

Several types of states

In React applications, depending on the form, I would divided states roughly into the following categories.

Remote state

This is a special category of state, and my introduction to the concept of "remote state" started with the swr and react-query libraries.

Remote state means that the source-of-truth is not actually in your local application runtime, but in a remote service - most of time in a DB. The state you read and update is ultimately reflected in changes to a piece of information stored on the remote service (i.e., a "resource" in REST). In fact, the core business logic of the front end of most BS architecture applications we develop is reading and updating the remote state (via APIs provided by the server).

function App() {
  // todos is not actually a value declared and maintained by the local application
  // but rather a copy of the previous message on the server side
  const [todos, setTodos] = useState([])

  useEffect(() => {
    api.fetchTodos().then(data => setTodos(data))
  }, [])

  return (
    <TodoList
      todos={todos}
      onCreateTodo={data => api.createTodo(data)}
      onEditTodo={(todo, data) => api.updateTodo(todo.id, data)}
      onDeleteTodo={todo => api.deleteTodo(todo.id)}
    />
  )
}

The remote state doesn't really need to be declared by you via useState. Maybe you used to do this (with useEffect to update), but now I recommend using libraries like swr or react-query, which provide intuitive declarative APIs and handle the request refresh, de-duplication, polling, and other "how to synchronize the remote state with the local copy at the right time " issues are handled very well.

function App() {
  const { data: todos } = useQuery("todos", api.fetchTodos)

  return (
    <TodoList
      todos={todos}
      onCreateTodo={data => api.createTodo(data)}
      onEditTodo={(todo, data) => api.updateTodo(todo.id, data)}
      onDeleteTodo={todo => api.deleteTodo(todo.id)}
    />
  )
}

Even if you're developing a small application with just a set of CRUD interfaces, I'd recommend using them instead of updating state yourself in useEffect (otherwise you have to deal with issues like Promise callbacks when components unmount).

Other than remote state is local state. Local state can be further broken down into the following categories.

Form state

From the perspective of the aforementioned "the core business logic of a front-end application is reading and modifying remote state", forms are the leading step to modifying remote state, providing the input for modifying it (and sometimes doing some client-side validation). Therefore, forms are a very important part of the front-end application.

The structure and updating logic of form state are often largely the same - structurally, it contains the value of the form fields, the error message of the form fields (considering validation); operationally, it contains the update of the form field value, the update of the error message (form validation), etc. Therefore, the community has wrapped this common logic into libraries such as formik and react-hook-form, which provide APIs for managing form state in the form of hooks. If you are going to create more robust forms, I suggest you use these libraries.

const {
  values,
  errors,
  setFieldValue,
  setFieldError,
  handleSubmit,
  isSubmitting,
} = useFormik({
  initialValues: {
    firstName: "",
    lastName: "",
    email: "",
  },
  onSubmit: values => {
    alert(JSON.stringify(values, null, 2))
  },
})

Also, I don't recommend using libraries that bind form state logic and form rendering together, claiming to help you write less code. I insist that logic and presentation are best left uncoupled.

UI state

Besides form state, the rest of the local state is the state that controls the UI. For example, whether the side navigation menu is expanded/collapsed, which Tab page/list item is currently selected, and so on.

Most of the time, their values are basic types and the updating logic is very simple.

const [isExpanded, setExpanded] = useState(false)
const [selectedTab, setSelectedTab] = useState("blog")

I only use useState at these times. In an application with 70,000+ lines of code, I only used about 20 useStates.

Sometimes, the type and update logic of these states is not that simple. I suggest you use a custom hook or reducer to encapsulate this state and update logic to improve predictability and testability. I won't go into that in this article.

Merge related states

In some scenarios, if you find that you are using multiple useStates in a row in the same component (while they are all necessary to implement the component), and often updating multiple states at the same time in a single callback, then the Mutable API may be good for you. Libraries such as use-immer, use-mutative, etc. that provide Mutable APIs allow you to update the state in a mutable way. e.g.

const [state, setState] = useMutative({
  foo: "bar",
  list: [{ text: "todo" }],
})
return (
  <button
    onClick={() => {
      // set value with draft mutable
      setState(draft => {
        draft.foo = `${draft.foo} 2`
        draft.list.push({ text: "todo 2" })
      })
    }}
  >
    click
  </button>
)

Apart from a preference in API style, Mutable API brings value by allowing us to combine multiple related states as a whole. For example, if we need to implement a list component with selectable items that:

  • At most one selected list item at the same time
  • When adding a new item, mark the new item as selected

we may need two states such as

const [items, setItems] = useState([{ id: 1 }])
const [selectedItemKey, setSelectedItemKey] = useState(1)

return (
  <button
    onClick={() => {
      setItems(items => [.. .items, { id: 2 }])
      setSelectedItemKey(2)
    }}
  >
    Add item
  </button>
)

With Mutable API it's like:

const [state, setState] = useMutative({
  items: [{ id: 1 }],
  selectedItemKey: 1,
})

return (
  <button
    onClick={() => {
      setState(state => {
        state.items.push({ id: 2 })
        state.selectedItemKey = 2
      })
    }}
  >
    Add item
  </button>
)

Doesn't it look more intuitive? And, writing it this way will only do one state update (but that's not critical, since newer versions of React already merge multiple consecutive state updates to avoid multiple re-renderings).

Wrap-up

What I'd like to suggest with this article is to use custom hooks, or existing libraries, instead of state scattered inside individual components whenever possible. This helps reduce coupling of logic and presentation, improves reusability and testability, and in the long run, improves maintainability.

When you look back at the code you wrote six months ago and get confused by the convoluted state update logic, try the approach in this article to make a change.


I'm not native English speaker and I'm practicing writing in English. Any suggestions that help me write better are welcome!


Profile picture

Written by Doma who just migrated his blog to Gatsby.js. You should follow him on Twitter and GitHub.