Since C++ 11, features like move semantics, resource management, and functional programming have completely shattered the prejudice that C++ is just “C with Classes.” People call this “Modern C++.” In fact, the React paradigm has also matured in recent years. I believe that React after version 18 can also be called “Modern React.” The updates over these years haven’t just been about adding a few Hooks; they represent a continued reform of the paradigm. We are moving from imperatively managing life cycles and state synchronization to declaratively defining resources and behaviors. We are stepping closer to true declarativeness.
Legacy React#
In the past, we were accustomed to using useEffect to handle all logic. To “peek” at a state (reading it without wanting it to be reactive), we might have deliberately ignored some dependencies in the dependency array, yet the linter would stop us from doing so. Things like eslint-ignore flooded our code.
Undoubtedly, this leads to code that is difficult to maintain. More insidiously, it confuses the coder’s brain—or rather, creates a chaotic understanding of the framework’s logic itself. In such a state, the code written can hardly be high-quality, highly readable, or modern.
Moreover, useEffect has deviated from its original intention: to synchronize internal state with external systems. Furthermore, if you are synchronizing an external store, useEffect cannot even be considered a good solution.
To protect performance under the re-rendering mechanism, useCallback and useMemo were also heavily used. This syntactic redundancy exacerbated the issues with dependency arrays and linters.
A Shift in Mental Model: Taking useEffectEvent as an Example#
In Modern React, useEffect must be reactive. If you need to read a state, but that state change shouldn’t trigger the useEffect, you should use useEffectEvent ↗.
Here is an example from the official documentation:
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme)
})
useEffect(() => {
const connection = createConnection(serverUrl, roomId)
connection.on('connected', () => {
onConnected()
})
connection.connect()
return () => connection.disconnect()
}, [roomId])
// ...
}jsshowNotification is a behavior, not part of the data flow, so it shouldn’t destroy the stability of the Effect.
You can use useEffectEvent to call showNotification and use this event inside useEffect without adding it to the dependency array.
However, note that the Effect Event function returned by useEffectEvent can only be used inside Effects (like useEffect).
The example above reflects the deepest philosophy of Modern React: let everything return to its origin. The dependency array should honestly declare reactive dependencies, and developers shouldn’t use clever hacks to achieve what are actually common goals.
Similarly, useEffect should only be used to synchronize React state with external systems and shouldn’t bear other responsibilities. Although this has been a principle declared by React from the beginning, it is only in the Modern React era that we have enough convenience to fully implement this principle. Let’s look further.
Modern Data Fetching and Synchronization#
If we use useEffect to fetch data, we need to manually set states like data, isLoading, and error, and manually handle race conditions. This should be viewed as an anti-pattern.
It creates a lot of boilerplate code and requires developers to master low-level issues during business development. This not only violates the declarative paradigm but makes it impossible to handle every case perfectly in practice. Moreover, it makes it difficult to implement features essential for modern applications, like caching and debouncing.
According to React philosophy, data should be a resource, not a side effect. Consider the following two points:
- Use libraries like SWR ↗ or TanStack Query ↗. In Safe & Consistent SWR, I tried to explain the best way to fetch and sync data using query libraries like
useSWR. It might be worth a look. - Use
useSyncExternalStore↗. This is a low-level primitive introduced in React 18. When dealing with browser APIs or global state libraries, you no longer needuseEffectto listen to and set state.- Furthermore, it solves issues under concurrent rendering, ensuring that the external data read by the UI is consistent within any given time slice, thus paving the way for the critical feature of concurrent rendering. We will see later how this changes the way we think about component rendering.
Derived State#
Have you or people around you written code like this?
function User({ firstName }) {
const [fullname, setFullname] = useState(null)
useEffect(() => {
setFullname(`Kumo ${firstName}`)
}, [firstName])
// ...
}jsReact teaches us that we shouldn’t use useEffect here; instead, directly derive state.
function User({ firstName }) {
const fullname = useMemo(() => `Kumo ${firstName}`, [firstName])
// ...
}jsWait… something is still not quite right! Every developer should know about the React Compiler ↗. For scenarios where you only want to ensure referential stability, don’t waste time manually memoizing anymore.
function User({ firstName }) {
const fullname = `Kumo ${firstName}`
// ...
}jsIf you don’t need explicit control over memoization behavior, please switch to React Compiler and handle derived state directly and naturally.
Concurrent Rendering#
Suppose we have a time-consuming operation. Set a pending state, then show a spinner!
This is indeed a very natural thought, but abusing it leads to frequent interface flickering, which in turn requires advanced handling techniques. This raises the barrier to writing good code and forces developers to focus on things they shouldn’t have to.
In the concurrent rendering mode, UI updates can be prioritized and can be “suspended.”
Components can use use ↗ to unwrap a Promise. Before the Promise is resolved, rendering will fall back to the nearest Suspense ↗ boundary.
This means we can handle asynchronous data just like writing synchronous code. However, this is from a low-level perspective; for actual applications, it is still recommended to handle this via mechanisms like useSWR.
What is Suspense? I recall Suspense wasn’t used for this before?
You are right. Suspense has taken on new responsibilities in Modern React, as described above. Once we start using Suspense instead of handling pending states inside components, we can look back at these scenarios:
- The user is typing in an input box, while the search and rendering of a Select component have a certain delay.
- Fetching data when opening a dropdown menu, but showing a spinner looks bad; passing data via router or context is an anti-pattern.
This means UI rendering can be deferred. useTransition ↗ demonstrates how to achieve this.
For example, in the data fetching for a dropdown menu, use the { suspense: true } option for useSWR, and then wrap the logic for opening the dropdown menu with startTransition.
You can use startTransition to render UI that requires suspense. This won’t actually revert to the Suspense boundary; React will mark these updates as non-urgent and will only display the interface once they are completed, thus avoiding the screen flickering issue.
Additionally, transitions can be widely applied in scenarios requiring non-blocking updates:
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY))
}
window.addEventListener(‘scroll’, handler, { passive: true })
return () => window.removeEventListener(‘scroll’, handler)
}, [])
}jsEncapsulating UI state-updating actions like setScrollY within startTransition ensures updates won’t block the UI.
Actions are also usable for Client-Side Apps#
Many people say these APIs are brought by RSC (React Server Components), but I don’t agree. Even in pure client-side applications, React 19’s Action API is still revolutionary.
<form onSubmit={handleSubmit}>preventDefaultinsidehandleSubmitsetPending(true), maybe consideringstartTransitionafter reading the above…?try/catchPOSTsetPending(false)/setError(...)- Ah… right! What about optimistic updates? 🤔🤯
I believe you are already disgusted by this boilerplate code. submit, as a mutation of data, shouldn’t be this complex.
useActionState↗ automatically manages the result and status of asynchronous operations; you just need to provide a handler function!useFormStatus↗ allows accessing form status in components like buttons inside the form.useOptimistic↗ lets us embrace modern UX! Show updates on the UI immediately; if it fails, automatically rollback.
The Final Comparison#
Modern React should be able to build applications like this:
const useMessages = (roomId) => useSWR(..., { suspense: true })
function ChatRoom({ roomId }) {
// messages will not be undefined; let's throw isLoading away
const { data: messages } = useMessages(roomId)
const [state, formAction, isPending] = useActionState(sendMessageAPI, null)
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage) => [...state, newMessage]
)
const activeCount = optimisticMessages.filter(m => !m.archived).length
return (
<div>
<MessageList messages={optimisticMessages} />
<div className="status">Active: {activeCount}</div>
<form action={async (formData) => {
const msg = formData.get('message')
addOptimisticMessage({ text: msg, sending: true })
await formAction(formData)
}}>
<input name="message" />
{/* use useFormStatus inside; no need to pass states */}
<SubmitButton />
</form>
</div>
)
}jsYou might have forgotten that before all this, the universe was chaos, and the Creator gave up on humanity because of messy code.
// Ahh i'm using isSubmitting :(
function SubmitButtonLegacy({ isSubmitting }) {
return (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
)
}
function ChatRoomLegacy({ roomId }) {
// Classic Trio!
const [messages, setMessages] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [fetchError, setFetchError] = useState(null)
useEffect(() => {
let ignore = false // Handling race conditions
async function fetchData() {
setIsLoading(true)
setFetchError(null)
try {
const data = await fetchMessagesAPI(roomId)
if (!ignore) {
setMessages(data)
}
} catch (err) {
if (!ignore) setFetchError(err)
} finally {
if (!ignore) setIsLoading(false)
}
}
fetchData() // Hmm. Honestly, I'd rather use .then.catch.finally
return () => {
ignore = true
}
}, [roomId]) // My favorite dependency array 🥰
// Here we go again.
const [isSending, setIsSending] = useState(false)
const [sendError, setSendError] = useState(null)
// We need a ref to prevent closure traps, or use functional updates
// We need to manually modify messages and rollback on failure
const handleSendMessage = async (e) => {
e.preventDefault() // Must prevent default behavior
const formData = new FormData(e.target)
const msgText = formData.get('message')
if (!msgText) return
// Step 1: Optimistic Update
const tempId = Date.now()
const optimisticMsg = { id: tempId, text: msgText, sending: true }
setMessages((prev) => [...prev, optimisticMsg])
setIsSending(true)
setSendError(null)
e.target.reset() // Manually clear the form
try {
// Step 2: Send Request
const newServerMsg = await sendMessageAPI(msgText)
// Step 3: Success: Replace temp data with real server data
setMessages((prev) =>
prev.map(m => m.id === tempId ? newServerMsg : m)
)
} catch (err) {
// Step 4: Failure: Manual rollback (very easy to get wrong!)
setSendError(err)
setMessages((prev) => prev.filter(m => m.id !== tempId))
// You even need to restore the input box content; omitted here because it's too much trouble
} finally {
setIsSending(false)
}
}
const activeCount = useMemo(() => {
return messages.filter(m => !m.archived).length
}, [messages]) // Carefully writing dependencies.
// Forgot the skeleton screen again. Whatever, I'll write a Loading text for now
if (isLoading) return <div>Loading...</div>
if (fetchError) return <div>Error: {fetchError.message}</div>
// Finally! After such a long buildup... Wait! Why does such simple UI need such verbose logic? I don't understand 😭
return (
<div>
<MessageList messages={messages} />
<div className="status">Active: {activeCount}</div>
<form onSubmit={handleSendMessage}>
<input name="message" disabled={isSending} />
<SubmitButtonLegacy isSubmitting={isSending} />
{sendError && <div className="error">Failed to send</div>}
</form>
</div>
)
}jsIn summary, in legacy React, to achieve the same functionality, developers need to manually write a massive amount of “glue code” to maintain state consistency. The code volume is not only large, but every manually maintained state is a potential source of bugs.
In this era where framework wars are intensifying, please use less code, greater robustness, and easily do the right thing. Let us vindicate React.