I have a useTypewriter hook that seems to skip the second iteration since the i never logs as 1. Why is the code not working? The output should be howdy
, not hwdy
.
Here is the full code:
const { 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 />
);
<script src=".3.1/umd/react.production.min.js"></script>
<script src=".3.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
I have a useTypewriter hook that seems to skip the second iteration since the i never logs as 1. Why is the code not working? The output should be howdy
, not hwdy
.
Here is the full code:
const { 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 />
);
<script src="https://cdnjs.cloudflare/ajax/libs/react/18.3.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
Share
Improve this question
edited Jan 29 at 16:34
Drew Reese
204k18 gold badges246 silver badges274 bronze badges
asked Jan 29 at 13:40
user8758206user8758206
2,2017 gold badges27 silver badges75 bronze badges
2
|
4 Answers
Reset to default 3There'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:
const 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);
};
const { 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 />);
<script src="https://cdnjs.cloudflare/ajax/libs/react/18.3.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script>
<div id="root" />
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:
const [i, setI] = useState(0);
And increment that state:
setI(i + 1);
And add it as a dependency for the useEffect
:
}, [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:
useEffect(() => {
const timeout = setTimeout(() => {
if (i < text.length) {
setDisplayText((prevText) => prevText + text.charAt(i));
setI(i + 1);
}
}, speed);
return () => {
clearTimeout(timeout);
};
}, [text, speed, i]);
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/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.
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++);
});
} else {
clearInterval(typingInterval);
}
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.
...
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.
发布者:admin,转转请注明出处:http://www.yc00.com/questions/1745294622a4621065.html
useEffect(() => runMyTimer((data) => {...}, [])
. And as an upside, now you can also just test your code properly. Although on a JS timer note, you typically don't want setInterval, but setTimeout that ends by scheduling a new call to itself, since JS timers don't guarantee that they actually run accurately, they only guarantee that they won't run faster than told. – Mike 'Pomax' Kamermans Commented Jan 29 at 15:46