This paper mainly analyzes Recoil asynchronous data flow is how to achieve
Introduction to the
Recoil is a React state management library that provides multiple independent, more fine-grained data sources for state management across components. Recoil maps state and derived state to the React component using a data flow diagram (see figure below) in which asynchronous functions can be used. For example, a Selector can rely asynchronously on server data or another Selector/Atom. This allows asynchronous data to be retrieved synchronously in the React component.
The CurrentUserInfo component directly fetches asynchronous data synchronously. The loading status of asynchronous data is processed by the React.Suepense component. The process is very concise.
const currentUserNameState = atom({ key: 'CurrentUserName', get: async () => { const userName = await getUserName(); return userName; }}); function CurrentUserInfo() { const userName = useRecoilValue(currentUserNameState); return <div>{userName}</div>; } function App() { return <RecoilRoot ><CurrentUserInfo /</RecoilRoot>; }Copy the code
The principle of
Recoil’s underlying data flow implementation is very similar to Redux. It uses a globally centralized state to manage data, but it does not separate state management from component library binding like Redux does. Instead, it deeply binds state management to React. The main reason is that a lot of recoil’s work is on how to handle state-to-component mapping, concurrent mode adaptation, etc., providing hook apis for reading and writing data.
The React component uses the read API to fetch data. The read API does two things: reads data from state (map data structure) with a key, and subscribes to changes in the data to the component. When data changes, such as when a component changes data by writing to it or after the async is over, it directly changes the data in state and then triggers the update of the component. One of Recoil’s innovations is that the global state and Atom are decoupled, and atom initialization is done on the first read, which allows Atom to be created dynamically and easily split and reused.
The React component can obtain asynchronous data synchronously because the read process is synchronous and the write process is asynchronous. If the write process is asynchronous, the loading state is first written to the state. When the asynchronous process ends, the final value is written to the State. Therefore, when the React component reads asynchronous data, it first reads a loading state, and then reads an end state when the asynchronous data ends. The end state contains two types of data: success data and error data.
With the CurrentUserInfo example above, let’s see how Recoil does this.
- With the RecoilRoot component, you will need a component that uses recoil status management for wrapping. The RecoilRoot component creates a global object from the Context to store state, listen for callbacks, etc. AtomValues is used to store all atom/selector state. NodeToComponentSubscriptions preserved the components of atom/selector that subscribe to the callback
function makeEmptyTreeState(): TreeState {
const version = getNextTreeStateVersion();
return {
version,
stateID: version,
transactionMetadata: {},
dirtyAtoms: new Set(),
atomValues: persistentMap(),
nonvalidatedAtoms: persistentMap(),
};
}
function makeEmptyStoreState(): StoreState {
const currentTree = makeEmptyTreeState();
return {
currentTree,
nextTree: null,
previousTree: null,
commitDepth: 0,
knownAtoms: new Set(),
knownSelectors: new Set(),
transactionSubscriptions: new Map(),
nodeTransactionSubscriptions: new Map(),
nodeToComponentSubscriptions: new Map(),
queuedComponentCallbacks_DEPRECATED: [],
suspendedComponentResolvers: new Set(),
graphsByVersion: new Map().set(currentTree.version, graph()),
versionsUsedByComponent: new Map(),
retention: {
referenceCounts: new Map(),
nodesRetainedByZone: new Map(),
retainablesToCheckForRelease: new Set(),
},
nodeCleanupFunctions: new Map(),
};
}
Copy the code
- The atom function creates the node node. There is no other operation to this step. The node is initialized while reading data, so it can be created on the fly. CurrentUserNameState is a node created with Atom.
const node = registerNode(
({
key,
nodeType: 'atom',
peek: peekAtom,
get: getAtom,
set: setAtom,
init: initAtom,
invalidate: invalidateAtom,
shouldDeleteConfigOnRelease: shouldDeleteConfigOnReleaseAtom,
dangerouslyAllowMutability: options.dangerouslyAllowMutability,
persistence_UNSTABLE: options.persistence_UNSTABLE
? {
type: options.persistence_UNSTABLE.type,
backButton: options.persistence_UNSTABLE.backButton,
}
: undefined,
shouldRestoreFromSnapshots: true,
retainedBy,
}: ReadWriteNodeOptions<T>),
);
return node;
Copy the code
- The CurrentUserInfo component reads the data through the useRecoilValue hook, finds the node of the previous step through the key, calls the init method for initialization, and then calls the get method to get the status data. You can see that the data is read directly from state.atomvalues when it is fetched
function getAtom(_store: Store, state: TreeState): Loadable<T> { if (state.atomValues.has(key)) { // Atom value is stored in state: return nullthrows(state.atomValues.get(key)); } else { return defaultLoadable; }}Copy the code
Read the data, set the following callback function to the global nodeToComponentSubscriptions, the change of the monitoring data, trigger component updates, trigger component update here USES is useState implementation, to set up a new object. In addition, React 18 provides a new API, useSyncExternalStore, that allows the React component to subscribe to changes in external data sources and trigger component updates. Recoil also implemented this API, which is not demonstrated here
const [, forceUpdate] = useState([]); useEffect(() => { const store = storeRef.current; const storeState = store.getState(); const subscription = subscribeToRecoilValue( store, recoilValue, _state => { if (! gkx('recoil_suppress_rerender_in_callback')) { return forceUpdate([]); } const newLoadable = getLoadable(); if (! prevLoadableRef.current? .is(newLoadable)) { forceUpdate(newLoadable); } prevLoadableRef.current = newLoadable; }, componentName, ); }Copy the code
- Atom initializing, if default is an asynchronous data, will trigger component update when the asynchronous data ends, i.e. MarkRecoilValueModified trigger component update and pull the latest data again. CurrentUserNameState is initialized on the first read, and upon completion of asynchronous initialization the notifyDefaultSubscribers callback below is executed.
function initAtom(
store: Store,
initState: TreeState,
trigger: Trigger,
): () => void {
liveStoresCount++;
const cleanupAtom = () => {
liveStoresCount--;
cleanupEffectsByStore.get(store)?.forEach(cleanup => cleanup());
cleanupEffectsByStore.delete(store);
};
store.getState().knownAtoms.add(key);
// Setup async defaults to notify subscribers when they resolve
if (defaultLoadable.state === 'loading') {
const notifyDefaultSubscribers = () => {
const state = store.getState().nextTree ?? store.getState().currentTree;
if (!state.atomValues.has(key)) {
markRecoilValueModified(store, node);
}
};
defaultLoadable.contents.finally(notifyDefaultSubscribers);
}
}
Copy the code
- Store on markRecoilValueModified invokes RecoilRoot kit. ReplaceState data update, update complete data, through notifyBatcherOfChange. Update current trigger component
const replaceState = replacer => {
startNextTreeIfNeeded(storeRef.current);
// Use replacer to get the next state:
const nextTree = nullthrows(storeStateRef.current.nextTree);
let replaced;
try {
stateReplacerIsBeingExecuted = true;
replaced = replacer(nextTree);
} finally {
stateReplacerIsBeingExecuted = false;
}
if (replaced === nextTree) {
return;
}
// Save changes to nextTree and schedule a React update:
storeStateRef.current.nextTree = replaced;
if (reactMode().early) {
notifyComponents(storeRef.current, storeStateRef.current, replaced);
}
nullthrows(notifyBatcherOfChange.current)();
};
Copy the code
Replacer (nextTree) is passed in as a callback, passing data updates to RecoilRoot, and there are several ways to handle data changes.
- NotifyBatcherOfChange. Current is set up by Batcher component setNotifyBatcherOfChange, Batcher is RecoilRoot child components, the aim is to: The endBatch method is called the next time a component finishes rendering.
function Batcher({
setNotifyBatcherOfChange,
}: {
setNotifyBatcherOfChange: (() => void) => void,
}) {
const storeRef = useStoreRef();
const [, setState] = useState([]);
setNotifyBatcherOfChange(() => setState({}));
useEffect(() => {
setNotifyBatcherOfChange(() => setState({}));
// If an asynchronous selector resolves after the Batcher is unmounted,
// notifyBatcherOfChange will still be called. An error gets thrown whenever
// setState is called after a component is already unmounted, so this sets
// notifyBatcherOfChange to be a no-op.
return () => {
setNotifyBatcherOfChange(() => {});
};
}, [setNotifyBatcherOfChange]);
useEffect(() => {
// enqueueExecution runs this function immediately; it is only used to
// manipulate the order of useEffects during tests, since React seems to
// call useEffect in an unpredictable order sometimes.
Queue.enqueueExecution('Batcher', () => {
endBatch(storeRef);
});
});
return null;
}
Copy the code
- EndBatch method will listen callback (nodeToComponentSubscriptions), trigger component updates
function notifyComponents( store: Store, storeState: StoreState, treeState: TreeState, ): void { const dependentNodes = getDownstreamNodes( store, treeState, treeState.dirtyAtoms, ); for (const key of dependentNodes) { const comps = storeState.nodeToComponentSubscriptions.get(key); if (comps) { for (const [_subID, [_debugName, callback]] of comps) { callback(treeState); } } } } function sendEndOfBatchNotifications(store: Store) { const storeState = store.getState(); const treeState = storeState.currentTree; // Inform transaction subscribers of the transaction: const dirtyAtoms = treeState.dirtyAtoms; if (dirtyAtoms.size) { if (! reactMode().early || storeState.suspendedComponentResolvers.size > 0) { // Notifying components is needed to wake from suspense, even when using // early rendering. notifyComponents(store, storeState, treeState); // Wake all suspended components so the right one(s) can try to re-render. // We need to wake up components not just when some asynchronous selector // resolved, but also when changing synchronous values because this may cause // a selector to change from asynchronous to synchronous, in which case there // would be no follow-up asynchronous resolution to wake us up. // TODO OPTIMIZATION Only wake up related downstream components storeState.suspendedComponentResolvers.forEach(cb => cb()); storeState.suspendedComponentResolvers.clear(); } } // Special behavior ONLY invoked by useInterface. // FIXME delete queuedComponentCallbacks_DEPRECATED when deleting useInterface. storeState.queuedComponentCallbacks_DEPRECATED.forEach(cb => cb(treeState)); storeState.queuedComponentCallbacks_DEPRECATED.splice( 0, storeState.queuedComponentCallbacks_DEPRECATED.length, ); }Copy the code
Refer to the figure below for detailed data flow
Loading process
When processing asynchronous data, there will be Loading state, Recoil has two processing methods
- Manual processing
Manually consuming loading state through useRecoilValueLoadable hook
function UserInfo({userID}) {
const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
switch (userNameLoadable.state) {
case 'hasValue':
return <div>{userNameLoadable.contents}</div>;
case 'loading':
return <div>Loading...</div>;
case 'hasError':
throw userNameLoadable.contents;
}
}
Copy the code
- Global automatic processing
Suspense automatically consumes loading state through React.suspense, where components get loading state data and throw asynchronous errors (throw promises).Suspense shows fallbacks. Suspense also retriggers component rendering to get the latest data at the end of the promise