I see from a million other questions that there is a callback that can be used after setState is called. In fact, I use it in my code example here in my questionChangeSystemCallback function.
I'm unsure how to take advantage of this in my situation. Here is my code (stripped for simplicity sake)
The main flow works like this: A question changes, it calls it's callback, questionChangeSystemCallback. From there it updates it's value in state. When done updating it's value, it checks for additional things to do and calls actionExecuter as needed.
//note, the part that matters is effectively the forEach loop at the bottom that's within the setState questionsData callback.
questionChangeSystemCallback(Q) {
// updates current state of questionsData, checks for action string, executes any actions
if (Q == null) {
console.log("questionChangeSystemCallback required field, question, is null");
}
let updatedQuestionData = this.getQuestionDataWithUpdatedValue(Q);
this.setState({ questionsData: updatedQuestionData }, () => {
// after state is set, check if there are additional actions needed based on the actionOptions
if (Q.props.actions) {
let { actions } = Q.props;
let qval = Q.state.value;
let commandString = actions[qval];
if (commandString) {
let actionsToDo = commandString.split('&');
actionsToDo.forEach((action) => {
this.actionExecuter(action);
});
}
}
});
}
actionExecuter does this... basically just a switch statement to call showTab with a true or fall element:
actionExecuter = (actionString) => {
let splitActionString = actionString.split('::');
if (splitActionString.length !== 2) {
//TODO: Throw error
}
switch (splitActionString[0]) {
case "ShowTab":
this.showTab(splitActionString[1], true);
break;
case "HideTab":
this.showTab(splitActionString[1], false);
break;
default:
console.log("Requested action '" + splitActionString[0] + "' not recognized");
}
}
showTab looks like this, effectively adding tabName to this.state.hiddenTabs if toShow is true and removing tabName from this.state.hiddenTabs if it's false... and then setting the state.hiddenTabs to the new array.
showTab(tabName, toShow) {
let newHiddenTabs = this.state.hiddenTabs.slice();
console.log("After copy: ", newHiddenTabs);
let cleanTabName = tabName.replace(/ /g, '');
if (toShow) {
// remove all instances from newHiddenTabs
while (newHiddenTabs.includes(cleanTabName)) {
let index = newHiddenTabs.indexOf(cleanTabName);
if (index > -1) {
newHiddenTabs.splice(index, 1);
}
}
console.log("After removal: ", newHiddenTabs);
} else {
// add tabName to newHiddenTabs
newHiddenTabs.push(cleanTabName);
console.log("After addition: ", newHiddenTabs);
}
console.log("Before setting state: ", newHiddenTabs);
this.setState({ hiddenTabs: newHiddenTabs }, ()=> {
console.log("STATE after setting state: ", this.state.hiddenTabs);
}
);
}
Using that host of console logs, I'm learning 1) the logic here works and 2) that if I have more than one 'action', and thus showTab gets called twice... only the data from the SECOND call ends up in state. Further, the render method does not get called afterward.
As an example:
initial this.state.hiddenTabs = ["WaterQuality","FieldForm","EWI","EDI"]
I have added a console.log("RENDER") to the top of my render function.
I run, as actions, a ShowTab(EDI, true) and a ShowTab(EWI, false).
The following is the output:
After copy: (4) ["WaterQuality", "FieldForm", "EWI", "EDI"]
**(correct)**
After removal: (3) ["WaterQuality", "FieldForm", "EWI"]
**(correct)**
Before setting state: (3) ["WaterQuality", "FieldForm", "EWI"]
**(correct)**
After copy: (4) ["WaterQuality", "FieldForm", "EWI", "EDI"]
**(nope - same initial state as first time through)**
After addition: (5) ["WaterQuality", "FieldForm", "EWI", "EDI", "EWI"]
**(given it's input, correct, but overall wrong)**
Before setting state: (5) ["WaterQuality", "FieldForm", "EWI", "EDI", "EWI"]
**(given it's input, correct, but overall wrong)**
RENDER
**(why are we rendering now... and why only once)**
STATE after setting state: (5) ["WaterQuality", "FieldForm", "EWI", "EDI", "EWI"]
**(this is the (erroneous) value from the second time through)**
STATE after setting state: (5) ["WaterQuality", "FieldForm", "EWI", "EDI", "EWI"]
**(this is the (erroneous) value from the second time through)**
Your setState calls are getting batched. Depending on where you're calling setState, React will batch them automatically and only perform a render, once the whole batch is finished.
Problem in your case is probably here:
let newHiddenTabs = this.state.hiddenTabs.slice();
When you have multiple actions, this function gets called multiple times and react is batching setState. Since the updates where not flushed yet, when it performs this action again, the state isn't updated yet!
My suggestion: Extract this to another function and use the other setState signature, which takes a function with prevState and props as argument.
It'd look somewhat like this:
showTab(tabName, toShow) {
const processTabs = (hiddenTabs) => {
let cleanTabName = tabName.replace(/ /g, '');
if (toShow) {
hiddenTabs = hiddenTabs.filter((tab) => tab !== cleanTabName)
} else {
hiddenTabs.push(cleanTabName)
}
return hiddenTabs;
}
this.setState((prevState, props) => ({ hiddenTabs: processTabs([...prevState.hiddenTabs])}), () => {
console.log("STATE after setting state: ", this.state.hiddenTabs);
})
}
Edit: Sorry, accidentally sent the answer incomplete before D:
Related
I have a react app that uses the MS Graph API (so it's a bit difficult to post a minimal reproducible example). It has a state variable called chats that is designed to hold the result of fetching a list of chats from the graph API. I have to poll the API frequently to get new chats.
I query the chats endpoint, build an array of newChats and then setChats. I then set a timeout that refreshes the data every 10 seconds (it checks for premature invocation through the timestamp property stored in the state). If the component is unmounted, a flag is set, live (useRef), which stops the refresh process. Each chat object is then rendered by the Chat component (not shown).
Here's the code (I've edited by hand here to remove some irrelevant bits around styles and event propagation so it's possible that typo's have crept in -- it compiles and runs in reality).
const Chats = () => {
const [chats, setChats] = useState({ chats: [], timestamp: 0 });
const live = useRef(true);
const fetchChats = () => {
if (live.current && Date.now() - chats.timestamp < 9000) return;
fetchData(`${baseBeta}/me/chats`).then(res => {
if (res.value.length === chats.chats.length) return;
const chatIds = chats.chats.map(chat => chat.id);
const newChats = res.value.filter(chat => !chatIds.includes(chat.id));
if (newChats.length > 0) {
setChats(c => ({ chats: [...c.chats, ...newChats], timestamp: Date.now() }));
}
setTimeout(fetchChats, 10000);
});
};
useEffect(() => {
fetchChats();
return () => (live.current = false);
}, [chats]);
return (
<div>
{chats.chats.map(chat => (
<Chat chat={chat} />
))}
</div>
);
};
The Chat component must also make some async calls for data before it is rendered.
This code works, for a second or two. I see the Chat component rendered on the screen with the correct details (chat member names, avatars, etc.), but almost before it has completed rendering I see the list elements being removed, apparently one at a time, though that could just be the way its rendered -- it could be all at once. The list collapses on the screen, showing that the chat state has been cleared out. I don't know why this is happening.
I've stepped through the code in the debugger and I can see the newChats array being populated. I can see the setChats call happen. If I put a breakpoint on that line then it is only invoked once and that's the only line that sets that particular state.
So, what's going on? I'm pretty sure React isn't broken. I've used it before without much trouble. What's changed recently is the inclusion of the refresh code. I'm suspicious that the reset is taking away the state. My understanding is that the fetchChats method will be rendered every time the chats state changes and so should see the current value of the chats state. Just in case this wasn't happening, I passed the chats state from the useEffect like this:
useEffect(() => {
fetchChats(chats);
return () => (live.current = false);
}, [chats]);
With the necessary changes in fetchChats to make this work as expected. I get the same result, the chats state is lost after a few seconds.
Edit
Still Broken:
After #Aleks answer my useEffect now looks like this:
useEffect(() => {
let cancel = null;
let live = true;
const fetchChats = () => {
if (Date.now() - chats.timestamp < 9000) return;
fetchData(`${baseBeta}/me/chats`).then(res => {
if (res.value.length === chats.chats.length) return;
const chatIds = chats.chats.map(chat => chat.id);
const newChats = res.value.filter(chat => chat.chatType === "oneOnOne" && !chatIds.includes(chat.id));
if (newChats.length > 0 && live) {
setChats(c => ({ chats: [...c.chats, ...newChats], timestamp: Date.now() }));
}
cancel = setTimeout(fetchChats, 10000);
});
};
fetchChats();
return () => {
live = false;
cancel?.();
};
}, []);
The result of this is that the chats are loaded, cleared, and loaded again, repeatedly. This is better, at least they're reloading now, whereas previously they would disappear forever. They are reloaded every 10 seconds, and cleared out almost immediately still.
Eventually, probably due to random timings in the async calls, the entries in the list are duplicated and the 2nd copy starts being removed immediately instead of the first copy.
There are multiple problems. First this
setTimeout(fetchChats, 10000); will trigger
useEffect(() => {
fetchChats(chats);
return () => (live.current = false);
}, [chats])
You will get 2 fetches one after another.
But the bug you're seeing is because of this
return () => (live.current = false);
On second useEffect trigger, clean up function above with run and live.current will be forever false from now on.
And as Nikki9696 said you you need to clear Timeout in clean up function
The easiest fix to this is, probably
useEffect(() => {
let cancel = null;
let live = true;
const fetchChats = () => {
// not needed
//if ( Date.now() - chats.timestamp < 9000) return;
fetchData(`${baseBeta}/me/chats`).then(res => {
//this line is not needed
//if (res.value.length === chats.chats.length) return;
// remove all the filtering, it can be done elsewhere where
// you can access fresh chat state
//const chatIds = chats.chats.map(chat => chat.id);
//const newChats = res.value.filter(chat =>
//!chatIds.includes(chat.id));
if (res.value?.length > 0&&live) {
setChats(c => ({ chats: [...c.chats, ...res.value], timestamp: Date.now() }));
cancel = setTimeout(fetchChats, 10000);
}
});
};
fetchChats()
return () => { live=false; if(cancel)window.clearTimeout(cancel) };
}, []);
Edit: typo cancel?.() to window.clearTimeout(cancel);
Ok, I have an idea what's happening and how to fix it. I am still not sure why it is behaving like this, so please comment if you understand it better than me.
Basically, for some reason I don't understand, the function fetchChats only ever sees the initial state of chats. I am making the mistake of filtering my newly fetched list against this state, in which the array is empty.
If I change my useEffect code to do this instead:
setChats(c => {
return {
chats: [
...c.chats,
...res.value.filter(cc => {
const a = c.chats.map(chat => chat.id);
return !a.includes(cc.id);
})
],
timestamp: Date.now()
};
});
Then my filter is passed the current value of the state for chats rather than the initial state.
I thought that because the function containing this code is in the function that declares the chat state, whenever that state changed the whole function would be rendered with the new value of chats making it available to its nested functions. This isn't the case here and I don't understand why.
The solution, to only trust the values of the state that is handed to me during the setState (setChats) call, works fine and I'll go with it, but I'd love to know what is wrong with reading the state directly.
An Example I have linked below, that shows the problem I have.
My Problem
I have these two functions
const updatedDoc = checkForHeadings(stoneCtx, documentCtx); // returns object
documentCtx.setUserDocument(updatedDoc); // uses object to update state
and
convertUserDocument(stoneCtx, documentCtx.userDocument);
// uses State for further usage
The Problem I have is, that convertUserDocument runs with an empty state and throws an error and then runs again with the updated state. Since it already throws an error, I cannot continue to work with it.
I have tried several different approaches.
What I tried
In the beginning my code looked like this
checkForHeadings(stoneCtx, documentCtx);
// updated the state witch each new key:value inside the function
convertUserDocument(stoneCtx, documentCtx.userDocument);
// then this function was run; Error
Then I tried the version I had above, to first put everything into an object and update the state only once.
HavingconvertUserDocument be a callback inside of checkForHeadings, but that ran it that many times a matching key was found.
My current try was to put the both functions in seperate useEffects, one for inital render and one for the next render.
const isFirstRender = useRef(true);
let init = 0;
useEffect(() => {
init++;
console.log('Initial Render Number ' + init);
console.log(documentCtx);
const updatedDoc = checkForHeadings(stoneCtx.stoneContext, documentCtx);
documentCtx.setUserDocument(updatedDoc);
console.log(updatedDoc);
console.log(documentCtx);
isFirstRender.current = false; // toggle flag after first render/mounting
console.log('Initial End Render Number ' + init);
}, []);
let update = 0;
useEffect(() => {
update++;
console.log('Update Render Number ' + update);
if (!isFirstRender.current) {
console.log('First Render has happened.');
convertUserDocument(stoneCtx.stoneContext, documentCtx.userDocument);
}
console.log('Update End Render Number ' + update);
}, [documentCtx]);
The interesting part with this was to see the difference between Codesandbox and my local development.
On Codesandbox Intial Render was called twice, but each time the counter didn't go up, it stayed at 1. On the other hand, on my local dev server, Initial Render was called only once.
On both version the second useEffect was called twice, but here also the counter didn't go up to 2, and stayed at 1.
Codesandbox:
Local Dev Server:
Short example of that:
let counter = 0;
useEffect(()=> {
counter++;
// this should only run once, but it does twice in the sandbox.
// but the counter is not going up to 2, but stays at 1
},[])
The same happens with the second useEffect, but on the second I get different results, but the counter stays at 1.
I was told this is due to a Stale Cloruse, but doesn't explain why the important bits don't work properly.
I got inspiration from here, to skip the initial render: https://stackoverflow.com/a/61612292/14103981
Code
Here is the Sandbox with the Problem displayed: https://codesandbox.io/s/nameless-wood-34ni5?file=/src/TextEditor.js
I have also create it on Stackblitz: https://react-v6wzqv.stackblitz.io
The error happens in this function:
function orderDocument(structure, doc, ordered) {
structure.forEach((el) => {
console.log(el.id);
console.log(doc);
// ordered.push(doc[el.id].headingHtml);
// if (el.children?.length) {
// orderDocument(el.children, doc, ordered);
// }
});
return ordered;
}
The commented out code throws the error. I am console.loggin el.id and doc, and in the console you can see, that doc is empty and thus cannot find doc[el.id].
Someone gave me this simple example to my problem, which sums it up pretty good.
useEffect(() => {
documentCtx.setUserDocument('ANYTHING');
console.log(documentCtx.userDocument);
});
The Console:
{}
ANYTHING
You can view it here: https://stackblitz.com/edit/react-f1hwky?file=src%2FTextEditor.js
I have come to a solution to my problem.
const isFirstRender = useRef(true);
useEffect(() => {
const updatedDoc = checkForHeadings(stoneCtx.stoneContext, documentCtx);
documentCtx.setUserDocument(updatedDoc);
}, []);
useEffect(() => {
if (!isFirstRender.current) {
convertUserDocument(stoneCtx.stoneContext, documentCtx.userDocument);
} else {
isFirstRender.current = false;
}
}, [documentCtx]);
Moving isFirstRender.current = false; to an else statement actually gives me the proper results I want.
Is this the best way of achieving it, or are there better ways?
I have some recoil state, that i want to reset.
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
...
//should be used for flushing the global recoil state, whenever a user submits an invoice
const resetLabelInvoiceState = useResetRecoilState(labelInvoiceState);
const resetMetaDataState = useResetRecoilState(metadataState);
const resetGlobalAnnotationsState = useResetRecoilState(globalAnnotationState)
I have made function, that i suppoes to reset all the states like this. I have both tried with and without the reset function.
const flushRecoilState = () =>{
console.log('flushed state')
return(
resetLabelInvoiceState(),
resetMetaDataState(),
resetGlobalAnnotationsState()
)
}
...
flushRecoilState()
return history.push('/historyinvoices')
...
When i check the state it is not reset. Is it because the `useResetRecoilState´ is not working properly from the library, is not implemented properly, or is there some other problem.
I could just use the regular useRecoilState hook, and just set the state back to the default value.
Does anybody know why this could be?
I had the same problem today, it turns out to be my own fault. Just put it here for future reference.
My problem was that I changed the set method in the selector, if you customized the set method, you need to check if the incoming value is a DefaultValue.
const targetYear = selector({
key: 'targetYear',
get: ({get}) => get(targetYearAtom),
set: ({set, get}, method) => {
const currentTargetYear = get(targetYearAtom);
switch(method) {
case 'prevYear':
set(targetYearAtom, currentTargetYear - 1);
return;
case 'nextYear':
set(targetYearAtom, currentTargetYear + 1);
return;
default:
if (method instanceof DefaultValue) {
set(targetYearAtom, method);
}
return;
}
},
})
I use Redux for state management of React JS but I have an event handler relevant to a button in my class component like below:
handleStartBtn = e => {
const name = document.getElementById("nameInput").value;
const error = "You must set a value in this field";
this.props.createPoll(name);
if (!this.props.currentPoll) return;
cookies.set("assignments", `EHS_${this.props.currentPoll.uuid}`, {
path: "/"
});
this.props.history.push("/polling");
};
The createPoll function is in my actions file. after creating a poll I dispatched a success function and it hits the store then it changes the currentPoll property. but it seems like it will leave null after calling createPoll dispatched I must clicked on startBtn twice till the store to be updated. how can I use the value of store after dispatch method call?
You can try using componentDidUpdate() life cycle method for this issue.
handleStartBtn = e => {
const name = document.getElementById("nameInput").value;
const error = "You must set a value in this field";
this.props.createPoll(name);
};
componentDidUpdate(prevProps,prevState){
if(prevProps.currentPoll !== this.props.currentPoll){
cookies.set("assignments", `EHS_${this.props.currentPoll.uuid}`, {
path: "/"
});
this.props.history.push("/polling");
}
}
when you dispatch the action through this.props.createPoll(name); you redux state of currentPoll will be changed and then this change takes place the componentDidUpdate will be triggered and in here you can check it (though a proper condition) and continue what needs to be done after that.
My code is causing an unexpected amount of re-renders.
function App() {
const [isOn, setIsOn] = useState(false)
const [timer, setTimer] = useState(0)
console.log('re-rendered', timer)
useEffect(() => {
let interval
if (isOn) {
interval = setInterval(() => setTimer(timer + 1), 1000)
}
return () => clearInterval(interval)
}, [isOn])
return (
<div>
{timer}
{!isOn && (
<button type="button" onClick={() => setIsOn(true)}>
Start
</button>
)}
{isOn && (
<button type="button" onClick={() => setIsOn(false)}>
Stop
</button>
)}
</div>
);
}
Note the console.log on line 4. What I expected is the following to be logged out:
re-rendered 0
re-rendered 0
re-rendered 1
The first log is for the initial render. The second log is for the re-render when the "isOn" state changes via the button click. The third log is when setInterval calls setTimer so it's re-rendered again. Here is what I actually get:
re-rendered 0
re-rendered 0
re-rendered 1
re-rendered 1
I can't figure out why there is a fourth log. Here's a link to a REPL of it:
https://codesandbox.io/s/kx393n58r7
***Just to clarify, I know the solution is to use setTimer(timer => timer + 1), but I would like to know why the code above causes a fourth render.
The function with the bulk of what happens when you call the setter returned by useState is dispatchAction within ReactFiberHooks.js (currently starting at line 1009).
The block of code that checks to see if the state has changed (and potentially skips the re-render if it has not changed) is currently surrounded by the following condition:
if (
fiber.expirationTime === NoWork &&
(alternate === null || alternate.expirationTime === NoWork)
) {
My assumption on seeing this was that this condition evaluated to false after the second setTimer call. To verify this, I copied the development CDN React files and added some console logs to the dispatchAction function:
function dispatchAction(fiber, queue, action) {
!(numberOfReRenders < RE_RENDER_LIMIT) ? invariant(false, 'Too many re-renders. React limits the number of renders to prevent an infinite loop.') : void 0;
{
!(arguments.length <= 3) ? warning$1(false, "State updates from the useState() and useReducer() Hooks don't support the " + 'second callback argument. To execute a side effect after ' + 'rendering, declare it in the component body with useEffect().') : void 0;
}
console.log("dispatchAction1");
var alternate = fiber.alternate;
if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
// This is a render phase update. Stash it in a lazily-created map of
// queue -> linked list of updates. After this render pass, we'll restart
// and apply the stashed updates on top of the work-in-progress hook.
didScheduleRenderPhaseUpdate = true;
var update = {
expirationTime: renderExpirationTime,
action: action,
eagerReducer: null,
eagerState: null,
next: null
};
if (renderPhaseUpdates === null) {
renderPhaseUpdates = new Map();
}
var firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
if (firstRenderPhaseUpdate === undefined) {
renderPhaseUpdates.set(queue, update);
} else {
// Append the update to the end of the list.
var lastRenderPhaseUpdate = firstRenderPhaseUpdate;
while (lastRenderPhaseUpdate.next !== null) {
lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
}
lastRenderPhaseUpdate.next = update;
}
} else {
flushPassiveEffects();
console.log("dispatchAction2");
var currentTime = requestCurrentTime();
var _expirationTime = computeExpirationForFiber(currentTime, fiber);
var _update2 = {
expirationTime: _expirationTime,
action: action,
eagerReducer: null,
eagerState: null,
next: null
};
// Append the update to the end of the list.
var _last = queue.last;
if (_last === null) {
// This is the first update. Create a circular list.
_update2.next = _update2;
} else {
var first = _last.next;
if (first !== null) {
// Still circular.
_update2.next = first;
}
_last.next = _update2;
}
queue.last = _update2;
console.log("expiration: " + fiber.expirationTime);
if (alternate) {
console.log("alternate expiration: " + alternate.expirationTime);
}
if (fiber.expirationTime === NoWork && (alternate === null || alternate.expirationTime === NoWork)) {
console.log("dispatchAction3");
// The queue is currently empty, which means we can eagerly compute the
// next state before entering the render phase. If the new state is the
// same as the current state, we may be able to bail out entirely.
var _eagerReducer = queue.eagerReducer;
if (_eagerReducer !== null) {
var prevDispatcher = void 0;
{
prevDispatcher = ReactCurrentDispatcher$1.current;
ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
var currentState = queue.eagerState;
var _eagerState = _eagerReducer(currentState, action);
// Stash the eagerly computed state, and the reducer used to compute
// it, on the update object. If the reducer hasn't changed by the
// time we enter the render phase, then the eager state can be used
// without calling the reducer again.
_update2.eagerReducer = _eagerReducer;
_update2.eagerState = _eagerState;
if (is(_eagerState, currentState)) {
// Fast path. We can bail out without scheduling React to re-render.
// It's still possible that we'll need to rebase this update later,
// if the component re-renders for a different reason and by that
// time the reducer has changed.
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
{
ReactCurrentDispatcher$1.current = prevDispatcher;
}
}
}
}
{
if (shouldWarnForUnbatchedSetState === true) {
warnIfNotCurrentlyBatchingInDev(fiber);
}
}
scheduleWork(fiber, _expirationTime);
}
}
and here's the console output with some additional comments for clarity:
re-rendered 0 // initial render
dispatchAction1 // setIsOn
dispatchAction2
expiration: 0
dispatchAction3
re-rendered 0
dispatchAction1 // first call to setTimer
dispatchAction2
expiration: 1073741823
alternate expiration: 0
re-rendered 1
dispatchAction1 // second call to setTimer
dispatchAction2
expiration: 0
alternate expiration: 1073741823
re-rendered 1
dispatchAction1 // third and subsequent calls to setTimer all look like this
dispatchAction2
expiration: 0
alternate expiration: 0
dispatchAction3
NoWork has a value of zero. You can see that the first log of fiber.expirationTime after setTimer has a non-zero value. In the logs from the second setTimer call, that fiber.expirationTime has been moved to alternate.expirationTime still preventing the state comparison so re-render will be unconditional. After that, both the fiber and alternate expiration times are 0 (NoWork) and then it does the state comparison and avoids a re-render.
This description of the React Fiber Architecture is a good starting point for trying to understand the purpose of expirationTime.
The most relevant portions of the source code for understanding it are:
ReactFiberExpirationTime.js
ReactFiberScheduler.js
I believe the expiration times are mainly relevant for concurrent mode which is not yet enabled by default. The expiration time indicates the point in time after which React will force a commit of the work at the earliest opportunity. Prior to that point in time, React may choose to batch updates. Some updates (such as from user interactions) have a very short (high priority) expiration, and other updates (such as from async code after a fetch completes) have a longer (low priority) expiration. The updates triggered by setTimer from within the setInterval callback would fall in the low priority category and could potentially be batched (if concurrent mode were enabled). Since there is the possibility of that work having been batched or potentially discarded, React queues a re-render unconditionally (even when the state is unchanged since the previous update) if the previous update had an expirationTime.
You can see my answer here to learn a bit more about finding your way through the React code to get to this dispatchAction function.
For others who want to do some digging of their own, here's a CodeSandbox with my modified version of React:
The react files are modified copies of these files:
https://unpkg.com/react#16/umd/react.development.js
https://unpkg.com/react-dom#16/umd/react-dom.development.js