When calling setState within a React component, one might assume React directly handles the DOM update. For instance, if a component’s state changes to { clicked: true }, React re-renders it and updates the DOM to display the new element.
However, this raises a question: Is it React itself or React DOM that performs this action? Updating the DOM appears to be a responsibility of React DOM. Yet, this.setState() is invoked, not a function from React DOM, and the React.Component base class is defined within the core React package.
This leads to the central puzzle: How can setState(), originating from React.Component, facilitate DOM updates?
Disclaimer: Understanding these internal mechanisms is not essential for effective React development. This explanation is intended for those interested in the underlying architecture.
One might initially assume that the React.Component class directly incorporates logic for DOM updates. However, this assumption is challenged by how this.setState() functions across different environments.
For instance, React Native applications utilize components that also extend React.Component. These components invoke this.setState(), yet React Native interacts with native Android and iOS views, not the DOM. Similarly, testing tools like React Test Renderer and Shallow Renderer allow rendering components and calling this.setState() without any DOM interaction.
Furthermore, the ability to employ multiple renderers on a single page, such as ART components coexisting within a React DOM tree, indicates that a global flag or variable for state handling would be impractical.
Therefore, it becomes clear that React.Component must delegate the management of state updates to code specific to each platform. To understand this delegation, it’s helpful to examine the separation and purpose of React packages.
Understanding React Package Separation
A common misunderstanding is that the core React “engine” resides within the react package. This is not accurate.
Since the package split in React 0.14, the react package has primarily focused on exposing APIs for defining components. The majority of React’s implementation is found within its “renderers.”
Examples of these renderers include react-dom, react-dom/server, react-native, react-test-renderer, and react-art. Developers can even create their own custom renderers.
This architecture ensures the react package remains useful across various target platforms. Its exports, such as React.Component, React.createElement, React.Children utilities, and Hooks, are platform-agnostic. Components can import and use them consistently, whether running with React DOM, React DOM Server, or React Native.
Conversely, renderer packages expose platform-specific APIs, like ReactDOM.render(), which enable mounting a React component hierarchy into a DOM node. Each renderer offers a similar API. Ideally, most components should not need to import anything directly from a renderer, enhancing their portability.
The actual React “engine” that many envision is contained within each individual renderer. Many renderers incorporate a shared codebase known as the “reconciler.” A build process combines the reconciler code with the renderer-specific code into a single, optimized bundle for improved performance. While duplicating code might seem detrimental to bundle size, most React users typically only require one renderer at a time (e.g., react-dom).
In essence, the react package provides the means to use React features but remains unaware of their underlying implementation. The renderer packages (e.g., react-dom, react-native) are responsible for implementing React features and platform-specific logic. The shared “reconciler” code is an internal detail of these renderers.
Feature Updates and Renderer Synchronization
This package separation explains why updating both the react and react-dom packages is necessary for new features. For instance, when React 16.3 introduced the Context API, React.createContext() was exposed by the react package.
However, React.createContext() does not contain the actual implementation of the context feature. Its implementation varies between renderers, such as React DOM and React DOM Server. The createContext() function simply returns a set of plain objects:
// A bit simplified
function createContext(defaultValue) {
let context = {
_currentValue: defaultValue,
Provider: null,
Consumer: null
};
context.Provider = {
$$typeof: Symbol.for('react.provider'),
_context: context
};
context.Consumer = {
$$typeof: Symbol.for('react.context'),
_context: context,
};
return context;
}
When <MyContext.Provider> or <MyContext.Consumer> are used in code, the renderer determines how to process them. React DOM might manage context values in one manner, while React DOM Server could handle them differently.
Consequently, if the react package is updated to version 16.3 or higher without updating react-dom, the renderer will not recognize the specialized Provider and Consumer types. This often results in an older react-dom instance reporting these types as invalid.
A similar situation applies to React Native. Unlike React DOM, React Native releases are not directly tied to React releases; they follow an independent schedule. The updated renderer code is periodically synchronized into the React Native repository, typically every few weeks. This explains why new features become available in React Native on a different timeline compared to React DOM.
How setState() Connects to the Renderer
Given that the react package primarily defines components and the actual implementation resides in renderers like react-dom or react-native, the question remains: How does setState() within React.Component communicate with the correct renderer?
The mechanism involves each renderer setting a specific field on the component instance it creates. This field is named updater. Developers do not set this field; instead, renderers like React DOM, React DOM Server, or React Native assign it immediately after instantiating a component class:
// Inside React DOM
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMUpdater;
// Inside React DOM Server
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMServerUpdater;
// Inside React Native
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactNativeUpdater;
An examination of the setState implementation within React.Component reveals that its sole purpose is to delegate the update task to the renderer responsible for creating that specific component instance:
// A bit simplified
setState(partialState, callback) {
// Use the `updater` field to talk back to the renderer!
this.updater.enqueueSetState(this, partialState, callback);
}
For example, React DOM Server might choose to disregard a state update and issue a warning. In contrast, React DOM and React Native would allow their respective reconciler implementations to handle the update.
This explains how this.setState() can trigger DOM updates despite being defined within the generic React package. It accesses the this.updater field, which is populated by React DOM, thereby enabling React DOM to schedule and manage the update process.
Hooks and the Dispatcher Object
With an understanding of class components, the next consideration is Hooks. When first encountering the Hooks API proposal, developers often question how useState “knows what to do,” perceiving it as potentially more “magical” than this.setState() in a class component.
However, as explored, the setState() implementation in the base class has always been a facade, merely forwarding the call to the active renderer. The useState Hook operates on the same principle.
Instead of an updater field, Hooks utilize a “dispatcher” object. When functions like React.useState(), React.useEffect(), or other built-in Hooks are invoked, these calls are directed to the current dispatcher.
// In React (simplified a bit)
const React = {
// Real property is hidden a bit deeper, see if you can find it!
__currentDispatcher: null,
useState(initialState) {
return React.__currentDispatcher.useState(initialState);
},
useEffect(initialState) {
return React.__currentDispatcher.useEffect(initialState);
},
// ...
};
Each individual renderer configures this dispatcher before rendering a component:
// In React DOM
const prevDispatcher = React.__currentDispatcher;
React.__currentDispatcher = ReactDOMDispatcher;
let result;
try {
result = YourComponent(props);
} finally {
// Restore it back
React.__currentDispatcher = prevDispatcher;
}
For example, the React DOM Server’s implementation can be found here, and the reconciler implementation shared by React DOM and React Native is located here.
This dependency means that a renderer, such as react-dom, must access the identical react package from which Hooks are called. Otherwise, the component will not “see” the dispatcher. This can lead to issues if multiple copies of React exist within the same component tree, a problem Hooks implicitly encourage developers to resolve due to its potential for obscure bugs.
While not recommended for general use, it is technically possible to override the dispatcher for advanced tooling. For instance, React DevTools employs a specialized dispatcher to inspect the Hooks tree by capturing JavaScript stack traces. This practice is generally discouraged for application development.
This design also suggests that Hooks are not exclusively bound to React. Should other libraries wish to adopt similar primitive Hooks in the future, the dispatcher could theoretically be moved to a separate package and exposed as a first-class API with a more accessible name. However, premature abstraction is typically avoided until a clear need arises.
Both the updater field and the __currentDispatcher object exemplify dependency injection, a generic programming principle. In both scenarios, renderers “inject” the implementations of features like setState into the generic React package, allowing components to remain more declarative.
Developers using React typically do not need to concern themselves with these internal workings. The aim is for React users to focus on their application logic rather than abstract concepts like dependency injection. Nevertheless, for those curious about how this.setState() or useState() operate, this explanation provides insight.

