React State and Rerendering Tests

This article walks through several React state patterns and shows when each one causes a re-render.

Plain useState

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{count}
</div>
)
}

Plain variable

NO change

Mutating a local let does not trigger a re-render, so the UI keeps showing 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App() {
let count = 0

useEffect(() => {
const intervalId = setInterval(() => {
count += 1
}, 1000)
return () => clearInterval(intervalId)
}, [count])

return (
<div>
{count}
</div>
)
}

Pass state to child

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Component({ count }: { count: number }) {
return (
<div>
{count}
</div>
)
}

function App() {
const [count, setCount] = useState(0)

setInterval(() => {
setCount(count + 1)
}, 1000)

return (
<div>
<Component count={count} />
</div>
)
}

Pass variable to child

NO change

Parent never re-renders, so the child keeps receiving the initial 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Component({ count }: { count: number }) {
return (
<div>
{count}
</div>
)
}

function App() {
let count = 0

useEffect(() => {
const intervalId = setInterval(() => {
count += 1
}, 1000)
return () => clearInterval(intervalId)
}, [count])

return (
<div>
<Component count={count} />
</div>
)
}

State in hook

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const useCount = () => {
const [count, setCount] = useState(0)
return { count, setCount }
}

function App() {
const { count, setCount } = useCount()

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)

return () => clearInterval(intervalId)
}, [setCount])

return (
<div>
{count}
</div>
)
}

Change state in hook

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const useCount = () => {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return { count }
}

function App() {
const { count } = useCount()

return (
<div>
{count}
</div>
)
}

Plain ref

NO change

Updating ref.current does not trigger a re-render, so JSX still shows the first read.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App() {
const countRef = useRef(0)

useEffect(() => {
const intervalId = setInterval(() => {
countRef.current += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{countRef.current}
</div>
)
}

Ref in memo

NO change

useMemo(..., []) caches the initial 0; ref updates never recompute it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function App() {
const countRef = useRef(0)

useEffect(() => {
const intervalId = setInterval(() => {
countRef.current += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

const count = useMemo(() => countRef.current, [])

return (
<div>
{count}
</div>
)
}

Passing variable to child with memo

NO change

Parent never re-renders, so useMemo in the child never sees a new count.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function Component({ count }: { count: number }) {
const localCount = useMemo(() => count, [count])

return (
<div>
{localCount}
</div>
)
}

function App() {
let count = 0

useEffect(() => {
const intervalId = setInterval(() => {
count += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
<Component count={count} />
</div>
)
}

Passing states in a hook through another hook

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const useCount = () => {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return { count }
}

const useCount2 = (count2: number) => {
return { count2 }
}

function App() {
const { count } = useCount()
const { count2 } = useCount2(count)

return (
<div>
{count2}
</div>
)
}

Hook inside hook

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const useCount = () => {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return { count, setCount }
}

const useCount2 = () => {
const { count: count2 } = useCount()
return { count2 }
}

function App() {
const { count2 } = useCount2()

return (
<div>
{count2}
</div>
)
}

Change child only

Not change in parent

Child state updates re-render only the child; the parent’s local count stays 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function Component() {
const [localCount, setLocalCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setLocalCount(localCount => localCount + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{localCount}
</div>
)
}

function App() {
let count = 0

useEffect(() => {
const intervalId = setInterval(() => {
count += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{count}
<Component />
</div>
)
}

Ref within hook and pass out as returned value

NO Change

Returning countRef.current snapshots 0 each render; mutating the ref does not re-render.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const useCount = () => {
const countRef = useRef(0)

useEffect(() => {
const intervalId = setInterval(() => {
countRef.current += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

return { count: countRef.current }
}

function App() {
const { count } = useCount()

return (
<div>
{count}
</div>
)
}

Seed state from ref

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const useCount = (ref: React.RefObject<number>) => {
const [count, setCount] = useState(ref.current)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return { count }
}

function App() {
const countRef = useRef(10)
const { count } = useCount(countRef)

return (
<div>
{count}
</div>
)
}

Use ref together with state

Work as expected

ref mutations become visible only when something else triggers a render.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function App() {
const countRef = useRef(0)
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
countRef.current += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{count}
<br />
{countRef.current}
</div>
)
}

Use ref to trigger useEffect

No change

countRef.current in the dep array is only read on render; mutating the ref does not re-run the effect (may bump once on mount).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function App() {
const countRef = useRef(0)
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
countRef.current += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

useEffect(() => {
setCount(count => count + 1)
}, [countRef.current])

return (
<div>
{count}
</div>
)
}

Use variable to trigger useEffect

No change

Mutating countValue does not re-render, so the dep never changes after mount.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function App() {
let countValue = 0
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
countValue += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

useEffect(() => {
setCount(count => count + 1)
}, [countValue])

return (
<div>
{count}
</div>
)
}

Change variable that is dependency of useEffect

Not work

Clicking mutates count without setState, so React does not re-render or re-run the effect.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function App() {
const countRef = useRef(0)
let count = 0
const [changed, setChanged] = useState(false)

useEffect(() => {
if (countRef.current !== count) {
setChanged(true)
}
}, [count])

return (
<div>
<button onClick={() => count += 1}>Increment</button>
<br />
{changed ? 'changed' : 'not changed'}
</div>
)
}

Let useEffect dependent on a variable

NO change

Module-level mutation is invisible to React; [countValue] stays the mount-time snapshot.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let countValue = 0

function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
countValue += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

useEffect(() => {
setCount(countValue)
}, [countValue])

return (
<div>
{count}
</div>
)
}

Object as state but update value in object

NO change

Mutating then setState with the same object reference bails out (Object.is).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
const [countObject, setCountObject] = useState({ count: 0 })

useEffect(() => {
const intervalId = setInterval(() => {
countObject.count += 1
setCountObject(countObject)
}, 1000)
return () => clearInterval(intervalId)
}, [countObject])

return (
<div>
{countObject.count}
</div>
)
}

Object as state but update object

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App() {
const [countObject, setCountObject] = useState({ count: 0 })

useEffect(() => {
const intervalId = setInterval(() => {
setCountObject(countObject => ({ ...countObject, count: countObject.count + 1 }))
}, 1000)
return () => clearInterval(intervalId)
}, [countObject])

return (
<div>
{countObject.count}
</div>
)
}

Parent changes state while child changes variables

NO change in child

Child re-renders with parent but let count = 0 resets each render, so the display stays 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function Component () {
let count = 0

useEffect(() => {
const intervalId = setInterval(() => {
count++
}, 1000)
return () => clearInterval(intervalId)
}, [count])

return (
<div>
{count}
</div>
)
}

function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
<Component />
{count}
</div>
)
}

Parent changes state and pass the state to the child

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Component ({ count }: { count: number }) {
return (
<div>
{count}
</div>
)
}

function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
<Component count={count} />
</div>
)
}

Parent changes state and pass a variable to the child

NO change

The child component actually updated but the value is not passed in.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function Component ({ count }: { count: number }) {
return (
<div>
{count}
</div>
)
}

function App() {
const [count, setCount] = useState(0)
let count2 = 0

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
count2 += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
<Component count={count2} />
</div>
)
}

Parent changes state while child has memo with unchanged parameter

NO change as expected, and logs are not printed every seconds

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const Component = memo(({ count }: { count: number }) => {
console.log('count', count)
return (
<div>
{count}
</div>
)
})

function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
<Component count={0} />
</div>
)
}

With react context and state updates

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const ComponentContext = createContext<number>(0)

const ChildComponent = () => {
const count = useContext(ComponentContext)

return (
<div>
{count}
</div>
)
}


function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
<ComponentContext.Provider value={count}>
<ChildComponent />
</ComponentContext.Provider>
</div>
)
}

Functional updater

Work as expected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{count}
</div>
)
}

setState bail-out on same value

NO re-render (bail-out)

console.log runs on the first render only; later setCount(0) is ignored because the value is already 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function App() {
const [count, setCount] = useState(0)

console.log('render', count)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(0)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{count}
</div>
)
}

memo defeated by unstable props

Re-renders (memo defeated)

Parent re-renders every second and passes a new inline function each time, so memo cannot skip the child.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const Component = memo(({ onTick }: { onTick: () => void }) => {
console.log('child render')
return (
<div>
<button onClick={onTick}>tick</button>
</div>
)
})

function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{count}
<Component onTick={() => {}} />
</div>
)
}

External store with useSyncExternalStore

Work as expected

Mutating a module-level value alone does not re-render; notifying listeners via useSyncExternalStore does. subscribe registers the component, getSnapshot returns the current value, and calling listeners after storeCount += 1 schedules a re-render with the new snapshot.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
let storeCount = 0

const listeners = new Set<() => void>()

const subscribe = (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}

const getSnapshot = () => storeCount
const getServerSnapshot = () => 0

function increment() {
storeCount += 1
listeners.forEach((listener) => listener())
}

function CountDisplay() {
const count = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
return <p>Display: {count}</p>
}

function CountControls() {
const count = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
return (
<div>
<p>Controls: {count}</p>
<button onClick={increment}>+</button>
</div>
)
}

function App() {
useEffect(() => {
const id = setInterval(increment, 1000)
return () => clearInterval(id)
}, [])

return (
<div>
<CountDisplay />
<CountControls />
</div>
)
}

Remount via key

State resets on key change

Clicking the button changes key, remounts Component, and local count goes back to 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function Component() {
const [localCount, setLocalCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setLocalCount(localCount => localCount + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{localCount}
</div>
)
}

function App() {
const [key, setKey] = useState(0)

return (
<div>
<button onClick={() => setKey(key => key + 1)}>Remount</button>
<Component key={key} />
</div>
)
}

Children as props / slot pattern

Child does not re-render

SlowChild is created by the grandparent and passed as children. Middle App state updates do not re-render SlowChild. The same child written as JSX inside the ticking App would re-render.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function SlowChild() {
console.log('SlowChild render')
return <div>slow child</div>
}

function App({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0)

useEffect(() => {
const intervalId = setInterval(() => {
setCount(count => count + 1)
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
{count}
{children}
</div>
)
}

function Grandparent() {
return (
<App>
<SlowChild />
</App>
)
}

With react context and varialbe update

NO change

Provider never re-renders with a new value, so context consumers stay at 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const ComponentContext = createContext<number>(0)

const ChildComponent = () => {
const count = useContext(ComponentContext)

return (
<div>
{count}
</div>
)
}


function App() {
let count = 0

useEffect(() => {
const intervalId = setInterval(() => {
count += 1
}, 1000)
return () => clearInterval(intervalId)
}, [])

return (
<div>
<ComponentContext.Provider value={count}>
<ChildComponent />
</ComponentContext.Provider>
</div>
)
}