Skip to main content

That's not a state

· 6 min read
Krystian Sowiński
Barbara Wierzba-Sowińska

Working with states in React can be tricky due to their volatility. That's why it's crucial to be aware of best practices in managing states that will make your code simple and prone to bugs.

There are many challenges related to the usage of states, here are some possible issues you may stumble upon while working with React states.

Challenge I: you need to combine different props

React props are sometimes synchronized to a specific state. Let's say you have firstName and lastName as separate strings in props, and you need to have it as a single string fullName. The common mistake here would be to synchronize it with useEffect and useState.

interface Props {
firstName: string
lastName: string
}
function Component(props: Props) {
const {firstName, lastName} = props

const [fullName, setFullName] = useState()

useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
}

However, there's a better solution! Just derive values from other props that are logically connected with each other. Fixing it often means replacing many lines of code with a single one, which significantly improves the readability of our code.

interface Props {
firstName: string
lastName: string
}
function Component(props: Props) {
const {firstName, lastName} = props

const fullName = `${firstName} ${lastName}` // ✨
}

Challenge II: you need to combine different states

If we have several states within a component, we sometimes need to join two states together. Here, as well as in the previous example, it would be common to synchronize it with useEffect and extra useState.

function Component() {
const [firstName, setFirstName] = useState()
const [lastName, setLastName] = useState()

const [fullName, setFullName] = useState()

useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
}

We can simplify it by deriving the value from the current state, if the value results from those states.

function Component() {
const [firstName, setFirstName] = useState()
const [lastName, setLastName] = useState()

const fullName = `${firstName} ${lastName}` // ✨
}

Sometimes, when we have many related states, one results from the other, but it can have various forms. Let's say we're loading data for our component from an external source. We have a state telling us if it's loading or not (loading), a state for an error (error) and a state for our data (data). If it's loading, it's logical that there is no error. Nevertheless, we need to specify each state separately. If the request is successfull, we set our data and the loading state to false. In case of an error, we specify the error value and the loading state to false.

function Component() {
const [loading, setLoading] = useState()
const [data, setData] = useState()
const [error, setError] = useState()

function load() {
setLoading(true)

fetch(/* ... */)
.then((data) => {
setData(data)
setLoading(false)
})
.catch((error) => {
setError(error)
setLoading(false)
})
}
}

The approach described above may cause some issues. When setData is called, we should clean the error state. Similarly, when setError is called, we should clean the data state.

We could think about using a useEffect in order to synchronize those states, for example when data is non-nullish, we could set error to null. And likewise, when error is present, data could be set to null.

Here it should be stressed, that the issue could be even more complex and thus more difficult to solve.

function Component() {
const [loading, setLoading] = useState()
const [data, setData] = useState()
const [error, setError] = useState()

function load() {
setLoading(true)

fetch(/* ... */)
.then((data) => {
setData(data)
setLoading(false)
setError(null)
})
.catch((error) => {
setError(error)
setLoading(false)
setData(null)
})
}
}

A possible solution would be to use a very underrated hook, useReducer, that could return several values at the same time. Depending on what is our input, if we send the request, it would automatically switch to the loading state. When we get a response, we set the data and clean the error state along with loading status set to false. It's basically a synchronization of multiple states - we don't need to manage them separately.

The first parameter of the useReducer is a function that takes two parameters - the first one is the previous state, the second one is what we will call the state setter (setResponse) with.

const [response, setResponse] = useReducer((lastResponse, payload) => {
if (payload === null) {
return {
loading: true,
data: null,
error: null,
}
} else if (payload instanceof Error) {
return {
loading: false,
data: null,
error: payload,
}
} else {
return {
loading: false,
data: payload,
error: null,
}
}
})

function load() {
setResponse(null)

fetch(/* ... */)
.then((data) => setResponse(data))
.catch((error) => setResponse(error))
}

It could be extremely useful, when we want to perform an action based on our previous state. For example, if we want to refresh our data, we might want to add a loading indicator, but we still want the data to be visible.

Challenge IV: you want to keep the filtering on a page

Imagine you filled several filters of a table, but you then accidentally (or on purpose) refreshed the page. And you missed it all... Sounds annoying, doesn't it? Also, if we have many nested components, it may be quite a challenge to pass the state of our filters.

The solution would be to derive the state from the URL. Refreshing the page won't then remove our filters. All components, no matter how deeply nested they are, will still have their filter states easily accessible.

The URL itself may contain the state of the app, like current filtering/paging info (e.g. search term, page number).

import { useSearchParams } from "react-router-dom";

function useSearchQuery() {
const [searchParams, setSearchParams] = useSearchParams();

const query = searchParams.get('query')

function setQuery(query: string) {
setSearchParams({ query })
}

return [query, setQuery];
}

Challenge V: dealing with multiple tabs on a page

Sometimes it can be really tricky to navigate back/forward and still keep your active tab. The same problem occurs when you refresh a page - you simply loose the state.

However, there's a simple solution to this problem. Just keep the ID of the active tab as a hash in the URL. You can then easily share/bookmark a link to a specific tab and place in the application.

import { useNavigate, useLocation } from "react-router-dom";

function useActiveTab() {
const navigate = useNavigate();
const { hash } = useLocation()

const activeTab = hash.slice(1)

function setActiveTab(activeTab) {
navigate(`#${activeTab}`, { replace: true })
}

return [activeTab, setActiveTab];
}