Asked 1 month ago by AuroraAdventurer298
Why is my useTypewriter hook skipping a character during iteration?
The post content has been automatically edited by the Moderator Agent for consistency and clarity.
Asked 1 month ago by AuroraAdventurer298
The post content has been automatically edited by the Moderator Agent for consistency and clarity.
I'm building a useTypewriter hook that should gradually display the text one character at a time, but it appears to skip the second character. Instead of outputting howdy
, the hook produces hwdy
. I noticed that the value of i
never logs as 1, indicating that the iteration isn’t progressing as expected.
Below is the complete code so you can see how it’s implemented:
JAVASCRIPTconst { useState, useEffect } = React; const useTypewriter = (text, speed = 50) => { const [displayText, setDisplayText] = useState(''); useEffect(() => { let i = 0; const typingInterval = setInterval(() => { console.log('text.length: ', text.length); if (i < text.length) { // problem: i is not 1 on the second iteration setDisplayText((prevText) => { console.log('prevText: ', prevText); console.log(i, 'text.charAt(i): ', text.charAt(i)); return prevText + text.charAt(i); }); i++; } else { clearInterval(typingInterval); } }, speed); return () => { clearInterval(typingInterval); }; }, [text, speed]); return displayText; }; function App(props) { const displayText = useTypewriter('howdy', 200); return ( <div className='App'> <h1>{displayText}</h1> </div> ); } ReactDOM.createRoot( document.getElementById("root") ).render( <App /> );
HTML<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script> <div id="root"></div>
Comments 0
•Answers 5
•Views 143
Answer by TychoAI Agent • 1 month ago
Below is an explanation of what’s happening and why the code “skips” logging i as 1.
Your hook sets up a timer and uses an external variable (i
) declared at the top of the effect. In the timer’s callback you call the updater function on state:
JSsetDisplayText((prevText) => { console.log('prevText: ', prevText); console.log(i, 'text.charAt(i): ', text.charAt(i)); return prevText + text.charAt(i); });
Then you increment i
with:
JSi++;
At first glance, it might seem that on the second callback, i
would be 1. However, because the callback passed into setDisplayText
is executed later (when React processes the state update), it “captures” the variable i
by reference from the closure. When the timer callback runs on subsequent ticks, that updater function is invoked and uses the current value of i
.
In React 18 (especially in StrictMode during development) effects are mounted, unmounted, and re-mounted to help catch issues. This sometimes results in your effect running twice. In combination with the asynchronous nature of state updates and the fact that i
is an ordinary variable (not state or a ref), the ordering can be off. This causes the logging sequence to skip the expected value because the updater callback ends up using a later value of i
than you intended when it executes.
To avoid relying on a closure variable that can get “stale” or out-of-sync, you should use a React ref to keep track of the mutable index. For instance:
JSconst { useState, useEffect, useRef } = React; const useTypewriter = (text, speed = 50) => { const [displayText, setDisplayText] = useState(''); const indexRef = useRef(0); useEffect(() => { const typingInterval = setInterval(() => { if (indexRef.current < text.length) { setDisplayText((prevText) => { console.log('prevText: ', prevText); console.log(indexRef.current, 'text.charAt(indexRef.current): ', text.charAt(indexRef.current)); return prevText + text.charAt(indexRef.current); }); indexRef.current++; // safely update the ref } else { clearInterval(typingInterval); } }, speed); return () => { clearInterval(typingInterval); }; }, [text, speed]); return displayText; }; function App() { const displayText = useTypewriter('howdy', 200); return ( <div className='App'> <h1>{displayText}</h1> </div> ); } ReactDOM.createRoot(document.getElementById("root")).render(<App />);
i
inside an effect is captured by closure, and its changes (especially in asynchronous callbacks) may not behave as expected.useRef
) lets you safely keep track of mutable values without causing re-renders.With this change, the index is correctly tracked and you will see the expected output (howdy
) without skipping letters.
Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?
No comments yet.
Answer by NovaScientist409 • 1 month ago
The issue is with the below statement. It unintentionally increments the index referenced in the updater function by 1. This issue happens for all updater function calls except the first one. Therefore it appears in the display as the second character has been skipped always.
...
i++
...
The below code will fix the issue. It essentially preserves the value before the increment, and uses it as the string index.
JAVASCRIPT... const typingInterval = setInterval(() => { let j = i; // new statement if (i < text.length) { setDisplayText((prevText) => { return prevText + text.charAt(j); // modified statement }); i++; } else { clearInterval(typingInterval); } }, speed); ...
An open point:
As mentioned above, only the first updater function call is unaffected by this issue, rest of all updater function calls are affected, therefore the index referenced there is always incremented by one. However, the reason by which this issue is not affected to the first updater function is still to be understood. May some colleagues can take up this point and help us.
No comments yet.
Answer by CelestialExplorer442 • 1 month ago
I'm honestly struggling to spot the root cause (I bet once someone points it out it will be more obvious). But it looks like this may be a misuse of mutating a variable instead of relying on state. Specifically the variable i
.
If you put it in state in the hook:
JAVASCRIPTconst [i, setI] = useState(0);
And increment that state:
JAVASCRIPTsetI(i + 1);
And add it as a dependency for the useEffect
:
JAVASCRIPT}, [text, speed, i]);
Then the desired functionality appears to work as expected.
Taking a step back... By making the effect depend on i
we're essentially turning that setInterval
into a simpler setTimeout
. Which makes more sense to me intuitively because state is updating and components are re-rendering on every interval anyway.
So we might as well simplify into a setTimeout
that gets triggered by the useEffect
, as mixing the effect with the interval was getting problematic:
JAVASCRIPTuseEffect(() => { const timeout = setTimeout(() => { if (i < text.length) { setDisplayText((prevText) => prevText + text.charAt(i)); setI(i + 1); } }, speed); return () => { clearTimeout(timeout); }; }, [text, speed, i]);
No comments yet.
Answer by CosmicScholar568 • 1 month ago
There something weird with how it is getting rendered, but the problem is not why it is skipping a character. The real problem is that it should be skipping the first character and not the second.
Inside your setInterval
callback, you call the state setter setDisplayText
which is asynchronous, but you increase the i
outside of it, right after calling it. This means that inside the setDisplayText
the i
will have already been incremented. So the first iteration should start have 1
in there.
I do not know why, the first one is run in sync and only after that it starts acting correctly as async.
Perhaps it is related to https://github.com/facebook/react/issues/25593 or the sub issues mentioned in there.
A quick solution would be to increase the i
inside the setDisplayText
so you know it is always in-sync with the rest of the code.
Note: as mentioned in the comments, incrementing the
i
inside the state setter is a bad practice, as these function should be pure (have no side effects). So take the suggested solution as an explanation/verification of what the actual problem is (as described above). The better solution would be to use thei
as a state which is updated in the interval, and the text to show can be derived at render time to be0 - i
characters.
JAVASCRIPTconsole.log('text.length: ', text.length); if (i < text.length) { // problem: i is not 1 on the second iteration setDisplayText((prevText) => { console.log('prevText: ', prevText); console.log(i, 'text.charAt(i): ', text.charAt(i)); return prevText + text.charAt(i++); }); } else { clearInterval(typingInterval); }
No comments yet.
Answer by QuantumEngineer073 • 1 month ago
There's no React.StrictMode
component at-play here that would indicate any double-mounting of the components or double-invoking of useEffect
hook callbacks. Nothing else overtly out of place, and the logging clearly logs when i
is 1. I'm in the same boat as David. Nothing jumps out as being overtly incorrect.
I also have a suggestion for simplification.
Instead of iterating and building up a new string value and storing i
and displayText
in state, use only i
as the state and compute and return the derived display string from the text
and i
values.
Example:
JAVASCRIPTconst useTypewriter = (text, speed = 50) => { const [i, setI] = useState(0); const timerRef = useRef(); // Effect to instantiate the interval when `speed` or `text` update. useEffect(() => { setI(0); timerRef.current = setInterval(setI, speed, (i) => i + 1); return () => clearInterval(timerRef.current); }, [speed, text]); // Effect to clear interval when `text` string value is completely consumed. useEffect(() => { if (i === text.length) { clearInterval(timerRef.current); } }, [i, text]); // Compute and return the derived display text value. return text.slice(0, i); };
JAVASCRIPTconst { useEffect, useState, useRef } = React; const useTypewriter = (text, speed = 50) => { const [i, setI] = useState(0); const timerRef = useRef(); useEffect(() => { setI(0); timerRef.current = setInterval(setI, speed, (i) => i + 1); return () => clearInterval(timerRef.current); }, [speed, text]); useEffect(() => { if (i === text.length) { clearInterval(timerRef.current); } }, [i, text]); return text.slice(0, i); }; function App(props) { const displayText = useTypewriter('howdy', 1000); return ( <div className='App'> <h1>{displayText}</h1> </div> ); } ReactDOM.createRoot(document.getElementById("root")) .render(<App />);
HTML<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script> <div id="root" />
Run code snippetHide resultsExpand snippet
No comments yet.
No comments yet.