State and Lifecycle with Hooks
Learn how to manage component state and side effects using React Hooks. Master useState for state management and useEffect for lifecycle events and side effects.
Introduction: In this lesson, you'll learn how to use React's useState
and useEffect
Hooks by building a realistic InputField
component from scratch. We'll manage the input's state with useState
(making it a controlled component) and synchronize side effects with useEffect
– for example, updating the page title as someone types. Along the way, we'll naturally introduce the concept of controlled vs uncontrolled inputs. (We'll even get some help from Ollie the Owl, who's wiring up a search field for his app.)
Table of Contents
- Adding state to an input
- Syncing with useEffect
- Controlled vs uncontrolled inputs
- Making the input reusable
- Best practices for stateful components
- Recap
Adding state to an input
Let's start by giving our input field a memory. Ollie wants a search box that "remembers" what the user types. In React, adding memory (state) to a component is done with the useState
Hook. useState
lets a component hold onto a value between renders (state) and provides a function to update that value. We call useState
at the top level of our component to initialize a state variable.
For our InputField
, we'll use useState
to keep track of the current text in the input. We also need to update that state whenever the user types, using an onChange
event handler. Here's the first version of our InputField
component with state:
Loading syntax highlighting...
What's happening? We initialize a state variable inputValue
to an empty string. Every time the input changes (onChange
fires), we call setInputValue(event.target.value)
to store the latest text in state. The input's value is tied to inputValue
, so React controls the input's shown value based on state. This makes our input a controlled component – its content is driven by React state instead of the browser DOM. In other words, the React state is now the "single source of truth" for the input's value. If you type "Hello", inputValue
becomes "Hello" and the input displays "Hello" (since we set value={inputValue}
). If inputValue
changes, the rendered input updates to match.
Why use state here? By controlling the input with state, we can later add features like validation, dynamic enabling/disabling of buttons, or other interactive logic that respond to what the user has typed. In contrast, if we didn't use state (an uncontrolled input), the component wouldn't know what's in the text box until we query it. We'll explore controlled vs uncontrolled inputs more later, but for now, know that using useState
this way lets React manage the form data internally.
Syncing with useEffect
Now that our InputField
holds state, what if we want to do something whenever that state changes? For example, Ollie wants to display some feedback or perform an action every time the user types into the search box. This is where useEffect
comes in.
useEffect
is a Hook that lets your component synchronize with external systems or side effects. "Side effects" are anything that affects the outside world (like logging to console, updating the document title, fetching data, or setting timers) as opposed to calculating the UI. We can use useEffect
inside our component to run some code after each render (optionally, only when certain values change).
Let's enhance our InputField
with a side effect: whenever the input value changes, we'll update the page's title to reflect the current search term. This isn't something that affects the JSX output, but it's a useful side effect (and in a real app, you might trigger a search API call here). We'll use useEffect
with our input state as a dependency so that it runs on initial mount and whenever inputValue
changes:
Loading syntax highlighting...
Understanding the effect: The call useEffect(() => { ... }, [inputValue])
sets up an effect that runs after the component renders, whenever inputValue
changes (the state we listed in the dependency array). We update the browser's document.title
with a message that includes the latest input text, and also log to the console for demonstration. Because we included inputValue
in the dependency array, React knows to re-run this effect whenever inputValue
changes. If inputValue
doesn't change on a re-render, the effect won't run again.
When you run this component and start typing, you'll notice the page title updates on each keystroke. For example, if you type "Ollie", the tab's title will become Searching for "Ollie", and the console will show Search query updated: Ollie. This is a side effect in action – we're synchronizing our component's state with an external system (the browser's DOM, in this case) via useEffect
.
A note on cleanup: In our example, we didn't return a cleanup function from the effect because updating document.title
doesn't create any ongoing subscription or resource to clean up. However, if your effect sets up something like a timer, a network subscription, or an event listener, you should return a cleanup function to avoid memory leaks and unwanted behavior. React will call your cleanup before the next effect runs and when the component unmounts. For instance, if we used setTimeout
or subscribed to a WebSocket in the effect, we'd return a function that clears the timeout or disconnects the socket. (For our title update, no cleanup is needed.)
Controlled vs uncontrolled inputs
So far, we built our InputField
as a controlled component – the input's value is controlled by React state (via value
and onChange
). It's important to understand this term and its counterpart, uncontrolled component, especially as you create form inputs.
Controlled vs Uncontrolled: A controlled input is one that takes its current value from React state and notifies React of any changes (through onChange
). In other words, React handles the form data via component state. In our case, inputValue
state is the source of truth for the text box content.
On the other hand, an uncontrolled input is one where the form data is handled by the DOM itself, outside of React state. You do not pass a value
prop; instead, the input keeps track of its own value internally (like normal HTML behavior), and you might read it only when you need it (for example, on form submission). In React, you typically use a ref to access the value from an uncontrolled input.
Let's see a quick comparison. Here's an example of an uncontrolled text input using a ref:
Loading syntax highlighting...
In this uncontrolled version, we use defaultValue
to set the initial text, and we do not pass a value
prop. The browser manages the input's state. We only interact with the input when the form is submitted: using the ref (nameRef.current.value
) to get whatever the user typed. There's no useState
and no onChange
here. React is essentially hands-off with the input's content until we grab it at submit time.
Key differences: In a controlled input, every keystroke updates the state and causes a re-render, allowing React to respond as the user types (for example, validating or enabling a Submit button). In an uncontrolled input, you let the browser keep track until you need the value. This means controlled components give you more immediate control (useful for live validation, enforcing input formats, dynamic UI updates, etc.), whereas uncontrolled components can be simpler for basic use cases (less React code) and may have slightly better performance for very large or simple forms. For instance, if you have a huge form and only need the data when the user submits, using uncontrolled inputs can reduce the number of re-renders (since state isn't updating on each keystroke).
Which to use? In modern React, controlled components are more common because they play nicely with React's declarative approach and state-driven rendering. They enable features like instant field validation and conditional UI changes as input changes. Uncontrolled components are fine when you don't need to react to input immediately – for example, a simple contact form that you only care about on submit. Just remember that if you choose uncontrolled inputs, you'll typically use refs to get their values, and you lose the ability to easily intervene on each update.
Important: You should not mix the two approaches for the same element. An input must remain either controlled or uncontrolled for its entire lifetime in the DOM. For example, if you render <input value={someState} onChange={...} />
(controlled) initially, you shouldn't later switch to rendering it without a value prop or vice versa. React will issue a warning if an input "switches" from uncontrolled to controlled or controlled to uncontrolled mid-stream. To avoid this, always provide an initial state for controlled inputs (e.g. useState('')
) so that from the first render, the input has a value prop (even if empty). And ensure every controlled input has an onChange
handler to update its state – otherwise, the field will become read-only.
Making the input reusable
Right now, our InputField
component works, but it's a bit hardcoded – it always renders a basic text input with no label or customization, and it always starts empty. In a real component library, we'd want this InputField
to be reusable in different scenarios (search boxes, form fields, etc.). Let's refactor our component to be more flexible:
- Props for customization: We can allow callers of
InputField
to specify things like a placeholder text, or an initial value. We'll add props for these. - Maintaining internal state: We'll keep the
useState
inside to control the input, but we might also allow an externalonChange
callback prop so parent components can respond when the input changes (while we still manage the state internally).
Here's an improved InputField
component:
Loading syntax highlighting...
What changed? We added a props object to our component: placeholder
, initialValue
, and onChange
. Now when someone uses <InputField />
, they can do things like:
<InputField placeholder="Search..." />
to set a custom placeholder.<InputField initialValue="Hello" />
to have a default starting text in the field.<InputField onChange={handleSomething} />
to run an external function whenever the user types (in addition to updating internal state).
Inside the component, we use initialValue
as the argument to useState
– this means the state will start at that value (or '' if no initialValue
was passed). Note that this initialValue
is only used on the first render; if the parent passes a different initialValue
later, our component as written won't reset its state automatically. (That's a conscious design decision – if we wanted to fully control the value from outside, we'd treat the component as controlled via props, which is a more advanced pattern.)
We also forward any onChange
prop: if the parent provided one, we call it with the event. This allows the parent to know about changes (for example, maybe to update some global state or form state), while still letting our InputField
handle the actual input control. This kind of pattern is common in reusable components: the component manages its own state internally, but also accepts props for initial setup and callbacks for external reactions.
You could extend this further – for instance, allow a type
prop to make it work with password or number inputs, or add an id/label for accessibility – but those don't involve new Hook concepts. The key point is that our component is now reusable: we can drop <InputField>
into different parts of an app with different props, and we get a nicely encapsulated, stateful input each time. Each InputField
instance has its own state (value
) that won't conflict with other instances, because each call to useState
inside each component is isolated.
Best practices for stateful components
Before we conclude, let's highlight some best practices and common pitfalls when using useState
and useEffect
(and building stateful components like our InputField
):
- Call hooks at the top level: Always invoke hooks (like
useState
anduseEffect
) only at the top level of your component, not inside loops, conditions, or nested functions. Following the Rules of Hooks ensures hooks run in the same order on every render. For example, do putconst [text, setText] = useState('')
at the top, but don't wrap it in an if block. - Manage Effect dependencies: When using
useEffect
, include all variables that your effect uses in its dependency array. This way, the effect runs exactly when it needs to. If you omit the dependency array entirely, the effect will run after every render – which is usually unnecessary and can even cause infinite loops if the effect updates state every time. If you provide an empty array[]
, the effect runs only once on mount. In our example, we listed[inputValue]
so the effect runs on mount and wheneverinputValue
changes, and no more. - Clean up side effects: If your effect creates a resource or subscription (like starting a timer, adding an event listener, or subscribing to an API stream), return a cleanup function from the effect to tear it down. React will run the cleanup before the next effect invocation and on component unmount, preventing memory leaks. For instance, if you use
useEffect
tosubscribeToChat()
, return a function that callsunsubscribeFromChat()
. - Don't mutate state directly: Always use the state setter (or an updater function) returned by
useState
to update your state. Never modify state variables (especially objects or arrays) in place. For example, if you haveconst [user, setUser] = useState({ name: 'Ollie' })
, do not douser.name = 'Oscar'
. Instead, create a new object:setUser(prev => ({ ...prev, name: 'Oscar' }))
. This ensures React knows the state changed and can trigger a re-render. Direct mutations won't trigger re-renders and can lead to bugs. - Avoid redundant state or effects: Simpler is better. Don't use state for something you can compute from props or other state, and avoid using an Effect to do things that can be done in a normal event handler or during render. If no external system is involved, you probably don't need an effect at all. For example, instead of using an effect to derive a
fullName
fromfirstName
andlastName
state, just compute it directly in your JSX (const fullName = firstName + ' ' + lastName
). UnnecessaryuseEffect
logic makes code harder to follow and can degrade performance. - Keep inputs either controlled or uncontrolled: As mentioned earlier, do not switch an input between controlled and uncontrolled modes. If you use
value
withonChange
(controlled), always provide an initial state (like an empty string for text) so it's controlled from the start, and keep it controlled. Conversely, if you go with an uncontrolled input (usingdefaultValue
and refs), don't later add avalue
prop to it. Mixing these will cause React warnings and potential inconsistencies. In practice, this means ensuring youruseState
state is initialized (no undefined initial values for controlled fields) and every controlled field has anonChange
.
Following these best practices will help you avoid common bugs (like stale state, infinite re-renders, or memory leaks) as you work with React Hooks.
Recap
By building this InputField
component, you've learned a lot of React Hook fundamentals:
- Using useState for controlled inputs: You can add state to function components and tie an input's value to state, updating it with setState on every change. This gives you fine-grained control over form inputs.
- Using useEffect for side effects: You know how to trigger side effects (like updating the document title or logging data) in response to state changes or component lifecycle events, and how to control when an effect runs with dependency arrays.
- Controlled vs. uncontrolled components: You can explain the difference between controlled inputs (managed by React state) and uncontrolled inputs (managed by the DOM), and decide which approach to use in a given scenario. You also know not to mix the two for the same form field.
- Building reusable components: You extended a simple component with props (like placeholder, initial values, and callbacks) to make it flexible and reusable in different situations – a key skill for building your own component library.
- Best practices with Hooks: You're aware of important best practices – keeping Hooks at the top level, cleaning up effects, updating state immutably, and avoiding unnecessary use of effects or state – which will help you write clean and efficient React components.
With these fundamentals, you and Ollie the Owl are well-equipped to build even more complex components. Happy coding! 🦉🚀