WORK IN PROGRESS see tests
This repo is intention to describe and formalize state-management edge-cases.
Posted on October 17, 2019
🥳 TL;DR React State Tearing, Eventual Consistency and Time Traveling 🤓
One day I used to be a junior developer, having many problems with no solution for them. I used to struggle with many strange things with no explanation for them.
I learned how to overcome challenges and obstacles. I understood how to solve and avoid them in advance. I was studying algorithms and patterns in order to make things run smoothly, making my output more predictable year by year.
Ages later I had taken a plunge into React, and I was amazed how it simplified everything: bugs disappeared, everything performed well! How to make it more simple and easy to handle? That was the only question left.
These days are now left behind.
I have just cleared up that in a week with hooks I had created and solved more problems than in a whole year prior.
Once again I am a Junior Developer. Once again I am facing issues with no explanation for. I have to and I am going to explore new patterns to handle upcoming problems.
Join me at my journey.
A detective tragedy in 3 parts
Déjà vu is the feeling that one has lived through the present situation before. The phrase translates literally as "already seen".
One day a few different people met in one issue. They had a great conversation about the future concurrent rendering, which would drive the development of React-Redux v6 later.
The main problem was “tearing” - different time slices coexistence in one render(output). Some component might see the New State
, while others might still see the Old
. You, as a User
, will see both.
It was just a theoretical issue, "insignificance" of which was confirmed by a React team(after React-redux v6 failure). However here is an example which might prove it.
Anyway, the main point there is that a year ago it used to be a theoretical issue, which could be faced far ahead when React would become asynchronous concurrent.
Although, react is still synchronous, we had got a problem, it was brought not by that asynchronicity, but by hooks and closures - functional scopes we love javascript for.
It's common now, that the state is always partially in the past.
There was no such thing as the "past" with Class based components - there was the only one this
, and nothing else. And this
always represents the "present".
With hooks, well...
When you are do onClick
- it sees variables from the local -functional scope. From the "past" scope - only refs
represents the present.
When you are declare effect
there is no "past" - only the present. As a result, you don't know when some effect might trigger. "Past" and "Present" dependencies would be compared inside React.
When you are run effect
- it's already one time tick
in the past. Something might have already been changed, but not for effect
- it is frozen in time.
When you are running multiple effects
- they might affect each other, causing cascade and repetitive updates. Until they all are not finished - there is no past
and there is no present
- it's mixed, as long as every hook works by its own.
In RxJS world it's called glitches
- temporary inconsistencies emitted by Observables - and they are not considered as a problem.
Glitches
in React are also more about features than bugs. However, they are at least a big performance problem.
Let's create a few examples
Achilles and the tortoise paradox
To get started, let's pick a simple problem to deal with - event propagation speed
. The problem is easy to reproduce, and you might have already had this one... in case you have more than one state management system.
Let's imagine a pretty standard case - React, React-Router, React-Router-Redux and Redux.
Let's imagine you are changing the location. What would happen then?
location
changeshistory
updatesreact-router-redux
dispatches an update to redux store
dispatch
happens out of React cycle, so State is updated synchronously, and all connected
components are triggeredwithRouter
/useRouter
are reading data from the Context
, which is 👉not yet updated👈.history
update calls the next listener and we continueRouter
is updatedContext
is updatedwithRouter
components are triggered by Context updateSo, you did nothing wrong, but got a double render by mixing states with different event propagation speed as a result.
Good news - React-Redux v7 has solved this problem. it just uses the same Context as Redux-Router, resulting in the same"event propagation speed". However, any other state management, especially with a custom subscription model, might not solve the problem (yet).
There is a question. What would happen in case another render dispatched some events changing the state again?
Well, "Achilles, the Tortoise", and you will get more wasted renders.
However, you might think that this is not your problem. I wouldn’t go along with it. Let's have a look on the same(!) problem from a different perspective.
Have you heard about CAP theorem? The simplest possible description of it - there is no way to create the ideal state management.
The Ideal State
consist of:
Consistency
: every read
reads the "true" valueAvailability
: every read
or every write
does the jobPartition tolerance
: just keeps working as a whole when different parts are not alive.We don't have any problems concerning Availability
with any clientside state management. Still, we do have problems with Consistency
and Partition tolerance
. It doesn't matter what you are going to write, or just written - as long as the write
would be performed in the future
there is no "read" command. You have only what you already have in local closure, and that's "the past".
And I do have a good example for you:
search term
current page
in the local state
search-term
+ current page
if they have not been loaded beforecode is always the best way to explain
const SearchResults = ({searchTerm}) => {
const [page, setPage] = useState(0);
useEffect(
// load data
() => loadIfNotLoaded(searchTerm, page),
// It depends on these variables
[page, searchTerm]
);
return "some render";
}
Is everything all right? Definitely is, except a single point. Probably you shall reset page
on a term
update. It should work that way for a "new" search - start from the beginning.
const SearchResults = ({searchTerm}) => {
const [page, setPage] = useState(0);
useEffect(
// load data
() => loadIfNotLoaded(searchTerm, page),
// It depends on these variables
[page, searchTerm]
);
+ // reset page on `term` update
+ useEffect(
+ () => setPage(0),
+ [searchTerm]
+ );
return "some render";
}
So, what will happen when you update searchTerm
?
searchTerm
has changedsearchTerm
has changedsearchTerm
and old page
- it was old when this effect was created.setPage(0)
page
has changedsearchTerm
and new page
So - one change to props, 3 or 4 🖼 renderings of a component, 2 data fetches, one of which is incorrect - with new searchTerm
and old page
. Table flip!
Play with it:
This is the same Achilles and the Tortoise
case, when one update (page) was trying to reach the other (searchTerm), but the other is also moving.
Everything is broken. We went a few years back in time.
NOT FUNNY, probably there was a good reason to use Redux. And well we all were asked to use Redux as long as it is "correct" and helps get shit done "right".
Today we told not to use it, but due to other reason. Like it's too global.
Long story short - there are 2 ways to solve our problem.
Or set the key
to remount component, and reset it to the "right" values
<SearchResults searchTerm={value} key={value} />
I would say - this is the worst advice ever, as long as you are going to lose everything - local state, rendered DOM, everything. However, there is a way how to make it better, using theoretically the same key
principle
const SearchResults = ({ searchTerm }) => {
const [page, setPage] = useState(0);
const [key, setKey] = useState(null/*null is an object*/);
useEffect(
() => {
if (key) {// to skip the first render
console.log("loading", { page, searchTerm });
}
},
[key] // depend only on the "key"
);
// reset page on `term` update
useEffect(() => {
setPage(0);
console.log("changing page to 0");
}, [searchTerm]);
useEffect(() => {
setKey({});
// we are just triggering other effect from this one
}, [page, searchTerm]);
This time our loading
sideEffect would be called once, even with the "right" values provided.
searchTerm
or page
updatedpage
to 0From some point of view - we are just shifting effect in time...
Just accept the game rules, and make them play on your side
const SearchResults = ({searchTerm}) => {
// ⬇️ mirror search term ⬇️
const [usedSearchTerm, setSeachTerm ] = useState(searchTerm);
const [page, setPage] = useState(0);
// reset page on `term` update
useEffect(
() => setPage(0),
[searchTerm]
);
// propagare search term update
useEffect(
() => setSeachTerm(searchTerm),
[searchTerm]
);
useEffect(
// load data
() => loadIfNotLoaded(usedSearchTerm, page),
// It depends on these variables
// and they are in sync now
[page, usedSearchTerm]
);
return "some render";
}
searchTerm
first updates page
and usedSearchTerm
usedSearchTerm
and page
loads the data. And these variables are updated simultaneously now.The case is closed? Well, no - this pattern is not applicable if you have many variables. Let's try to understand the root problem:
Another name of this problem is a Diamond Problem
, which is also bound to Push or Pull variant of state update propagation.
PUSH
every update "informs" consumers
about the change. So once something is changed - the consumer
would be notified about the exact change. This is how hooks works.PULL
every consumer
got notified about "The Change", and then they have to pull
update from a store. This is how redux works.Problem with PULL
- no "exact change" notification, every consumer has to pull
by its own. This is why you have to use memoization and libraries like reselect.
Problem with PUSH
- if there is more than one change - consumer
might be called more than one time, causing temporary inconsistencies as well as DejaVu.
Here is a good diagram from a State Manager Expert™(and creator of reatom) - @artalar
This a cost caclulator
, with a cascade update caused by a PUSH pattern. Let's reimplement it with hooks:
const PriceDisplay = ({cost}) => {
const [tax, setTax] = useState(0);
const [price, setPrice] = useState(0);
// update tax on cost change
useEffect(() => setTax(cost*0.1), [cost]); // 10% tax
// update price - cost + tax
useEffect(() => setPrice(tax + cost), [cost, tax]);
return `total: ${price}`;
}
cost
is updated - we update tax
and price
tax
is updated - we update price
price
got updated twice, as well as this component, and probable some components below it was also updated.price
is "too fast"this was PUSH, and now let's rewrite it with PULL.
const PriceDisplay = ({cost}) => {
const tax = useMemo(() => cost * 0.1, [cost]); // 10% tax
const price = useMemo(() => tax + cost, [tax, cost]);
return `total: ${price}`;
}
You will not believe by ⬆️this⬆️ is the core of this article
Caching versus Memoization - we are deriving data, one from each other, in a synchronous way, which is a PULL pattern, and the result is free from the problems above.
However, there is a problem - exactly this example solves the problem for the calculator example, but not for our paginated search
.
However, ... let's try to solve it yet again
const useSynchronizedState = (initialValue, deps) => {
const [value, setValue] = useState(initialValue);
const refKey = useRef({});
// reset on deps change
useEffect(() => {
setValue(0);
}, deps);
// using `useMemo` to track deps update
const key = useMemo(() => ({}), deps);
// we are in the "right" state (deps not changed)
if (refKey.current === key) {
return [value, setValue];
} else {
refKey.current = key;
// we are in the "temporary"(updating) state
// return an initial(old) value instead of a real
return [initialValue, setValue];
}
};
const SearchResults = ({ searchTerm }) => {
const [page, setPage] = useSynchronizedState(0, [searchTerm]);
useEffect(
() => {
console.log("loading", { page, searchTerm });
},
[page, searchTerm]
);
Here is "fixed" code sandbox - https://codesandbox.io/s/hook-state-tearing-dh0us
It's not fixed. It's shifted - we have normalized state update propagation speed - values are updating in the same time.
Yet another way to solve this problem - is to change the way we dispatch the "side effect".
Speaking in terms of redux-saga - then "the State" dispatched multiple events you might takeLatest
, ignoring the firsts, or takeLeading
, ignoring the following.
You might also know this as debounce. I prefer to call this as Event Horizons
, or event propagation boundaries.
Any (any!) example here could be "fixed" by delaying the loading effect
, and actually executing only the last, the "stable", the correct one.
This is a very important concept, allowing batching requests and various optimizations - just accept that any asynchronous call can take a time, especially network requests. If you delay them by a few milliseconds or even CPU(or nodejs process ticks)... everything could become a little bit better.
However, keep in mind - adding any timeouts and other asynchronously to your code is making it harder.
useState
state is derived from props, only during the first render useMemo
other values are derived from state and propsuseEffect
some variations of props and state are reflected back to the state.ANY
useEffect
might cause glitches.
glitches
With different hooks updating independently you may, and will get temporary inconsistencies within a single component, leading to the (temporary)undefined behaviour or even (temporary) broken state.
The problem is bound to hooks, as long as you have to render a component to the very end, and could not "bail-out" if some useEffect
is supposed to synchronize states.
The problem is bound to the Caching
and Memoization
, which are differently affected by the CAP Theorem
- only memoization would not cause tearing.
glitches
are causing re-renders, and slowing your App
(surprise!) ClassComponents have got componentDidUpdate
as well as getDerivedStateFromProps
making complex state updates more handy. You just able to update them as one thing, without extra re-renders.
Please don't forget about classes - they could help a lot!
Redux is PULL, Redux is doing many small state updates as a response to a single dispatch, Redux could batch many state updates in one, resulting a single React render, making broken states
impossible.
Redux is awesome. And, let me be clear, less fragile
Just don't "trust" any single solution. I was quite pathetic in my attempts to solve some state problems with hooks, until I've accepted - there no is such thing as an ideal tool.
Hooks are not ideal
Yes. It's not a problem almost always. You might never face the terrible stories I've told you above.
Hooks are awesome!
... but, let's face the truth - state management is, and always will be a very complicated beast...
If you agree, as well as if you disagree with this - here is attempt to "document" all edge cases for different state management systems:
WORK IN PROGRESS see tests
This repo is intention to describe and formalize state-management edge-cases.
Posted on October 17, 2019
Sign up to receive the latest update from our blog.