We're going to start with a super simple counter.
Out of the box, it doesn't have a lot going on.
class Counter extends Component {
render() {
return (
<main className="Counter">
<p className="count">0</p>
<section className="controls">
<button>Increment</button>
<button>Decrement</button>
<button>Reset</button>
</section>
</main>
);
}
}
Let's get it wired up as a fun warmup exercise.
We'll start with a constructor method that sets the component state.
constructor(props) {
super(props);
this.state = {
count: 0,
};
}
We'll use that state in the component.
render() {
const { count } = this.state;
return (
<main className="Counter">
<p className="count">{count}</p>
<section className="controls">
<button>Increment</button>
<button>Decrement</button>
<button>Reset</button>
</section>
</main>
);
}
Alright, now we'll implement the methods to incrementm, decrement, and reset the count.
increment() {
this.setState({ count: this.state.count + 1 });
}
decrement() {
this.setState({ count: this.state.count - 1 });
}
reset() {
this.setState({ count: 0 });
}
We'll add those methods to the buttons.
<button onClick={this.increment}>Increment</button>
<button onClick={this.decrement}>Decrement</button>
<button onClick={this.reset}>Reset</button>
We need to bind those event listeners because everything is terrible.
constructor(props) {
super(props);
this.state = {
count: 3,
};
this.increment = this.increment.bind(this);
this.decrement = this.decrement.bind(this);
this.reset = this.reset.bind(this);
}
Okay, let's say we refactored increment()
as follows:
increment() {
this.setState({ count: this.state.count + 1 });
this.setState({ count: this.state.count + 1 });
this.setState({ count: this.state.count + 1 });
console.log(this.state.count);
}
Two questions:
- What will be logged to the console?
- What will the new value be?
this.setState
also takes a function. This means we could refactor increment()
as follows.
this.setState(state => {
return { count: state.count + 1 };
});
If we wanted to show off, we could use destructuring to make it evening cleaner.
increment() {
this.setState(({ count }) => {
return { count: count + 1 };
});
}
There are some potentally cool things we could do here. For example, we could add some logic to our component.
(Live Coding Starts Here)
this.setState((state, props) => {
if (state.count >= 10) return;
return { count: state.count + 1 };
});
Let's stay, we wanted to add in a maximum count as a prop.
render(<Counter max={10} />, document.getElementById('root'));
this.setState(state => {
if (state.count >= this.props.max) return;
return { count: state.count + 1 };
});
It turns out that we can actually have a second argument in there as well—the props
.
this.setState((state, props) => {
if (state.count >= props.max) return;
return { count: state.count + 1 };
});
Oh wait—what if we did this three times?
increment() {
this.setState((state, props) => {
if (state.count >= props.max) return;
return { count: state.count + 1 };
});
this.setState((state, props) => {
if (state.count >= props.max) return;
return { count: state.count + 1 };
});
this.setState((state, props) => {
if (state.count >= props.max) return;
return { count: state.count + 1 };
});
}
Oh, that's interesting.
The other thing we can do is pull that function out of the component. This makes it way easier to unit test.
const increment = (state, props) => {
if (state.count >= props.max) return;
return { count: state.count + 1 };
};
increment() {
this.setState(increment);
}
this.setState
takes a second argument in addition to either the object or function. This function is called after the state change has happened.
Here is the simplest possible implementation.
this.setState(increment, () => console.log('Callback'));
We can also do something like.
this.setState(increment, () => console.log(this.state));
Here is a simple thing we might choose to do.
this.setState(increment, () => (document.title = `Count: ${this.state.count}`));
Let's make a little helper function.
const getStateFromLocalStorage = () => {
const storage = localStorage.getItem('counterState');
if (storage) return JSON.parse(storage);
return { count: 0 };
};
We can then use a callback to set localStorage
when the state changes.
this.setState(increment, () =>
localStorage.setItem('counterState', JSON.stringify(this.state)),
);
Let's pull that out along with increment.
const storeStateInLocalStorage = () => {
localStorage.setItem('counterState', JSON.stringify(this.state));
};
increment() {
this.setState(increment, storeStateInLocalStorage);
}
It doesn't work. It's a bummer. It would be great if the callback function got a copy of the state, but it doesn't. We could wrap it into a function and then pass the state in.
We could handle this a few ways.
We could use an anonymous function and then pass it in as an argument.
const storeStateInLocalStorage = state => {
localStorage.setItem('counterState', JSON.stringify(state));
};
increment() {
this.setState(increment, () => storeStateInLocalStorage(this.state));
}
Alternatively, if we're willing to give up on arrow functions, we can use bind
.
function storeStateInLocalStorage() {
localStorage.setItem('counterState', JSON.stringify(this.state));
}
increment() {
this.setState(increment, storeStateInLocalStorage.bind(this));
}
Lastly, we can just put it onto the class component itself.
storeStateInLocalStorage() {
localStorage.setItem('counterState', JSON.stringify(this.state));
}
increment() {
this.setState(increment, this.storeStateInLocalStorage);
}
This is probably your best bet.
(Jump back into slides for "Patterns and Anti-patterns")
Hooks are a new pattern that allow us to write a lot less code. Get ready to delete some code.
Let's start by deleting everything but the render method.
const Counter = ({ max }) => {
const [count, setCount] = React.useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(0);
return (
<main className="Counter">
<p className="count">{count}</p>
<section className="controls">
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={reset}>Reset</button>
</section>
</main>
);
};
So, what don't we have to do here?
- We don't have to
bind
anything. - We dont need a reference to this.
- We don't need a
constructor
at all.
What if we tripled up again?
const increment = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
Okay, so that makes sense.
It also turns out that useState
setters can take functions too.
const increment = () => {
setCount(c => c + 1);
};
Unlike using values, using functions also works the same way as it does with this.setState
.
const increment = () => {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
};
There is an important difference. You get only the state in this case. There is no second argument with the props. That said, we have them in scope.
They also do not support callback functions like this.setState
. Later on, we'll use useEffect
to trigger side effects based on state changes.
Earlier with this.setState
, we ended up returning undefined
if our count had hit the max. What if we did something similar here?
setCount(c => {
if (c >= max) return;
return c + 1;
});
Oh—it explodes after ten. This is core to the difference between how useState
and this.setState
works.
With this.setState
, we're giving the component that object of values that it needs to update. With useState
, we've got a dedicated function to change a particular piece of state.
How can we fix this?
setCount(c => {
if (c >= max) return c;
return c + 1;
});
We're going to go a bit deeper into useEffect
, but let's do the high level now.
Use effect allows us to implement some kind of side effect in our component outside of the changes to state and props triggering a new render.
This is useful for a ton of reasons:
- Storing stuff in
localStorage
. - Making AJAX requests.
Let's get the basic set up in place here.
Here is a reminder of that function of getting from localStorage
.
const getStateFromLocalStorage = () => {
const storage = localStorage.getItem('counterState');
if (storage) return JSON.parse(storage);
return { count: 0 };
};
We'll read the count property from localStorage
.
const [count, setCount] = React.useState(getStateFromLocalStorage().count);
Now, we'll register a side effect.
React.useEffect(() => {
localStorage.setItem('counterState', JSON.stringify({ count }));
}, [count]);
The coolest part about this is that it works for increment
, decrement
, and reset
all at once.
Register an effect that updates the document title.
const getStateFromLocalStorage = (defaultValue, key) => {
const storage = localStorage.getItem(key);
if (storage) return JSON.parse(storage).value;
return defaultValue;
};
const useLocalStorage = (defaultValue, key) => {
const initialValue = getStateFromLocalStorage(defaultValue, key);
const [value, setValue] = React.useState(initialValue);
React.useEffect(() => {
localStorage.setItem(key, JSON.stringify({ value }));
}, [value]);
return [value, setValue];
};
Now, we just never think about it again.
const [count, setCount] = useLocalStorage(0, 'count');
Okay, now—let's switch over to the class-based implementation in component-state-completed
.
We'll add this:
componentDidUpdate() {
setTimeout(() => {
console.log(`Count: ${this.state.count}`);
}, 3000);
}
The delay is intended to just create some space between the click and what we long to the console
Let's switch to a Hooks-based component on the hooks
branch.
React.useEffect(() => {
setTimeout(() => {
console.log(`Count: ${count}`);
}, 3000);
}, [count]);
That's a much different result, isn't it?
Could we implement the older functionality with this newer syntax?
const countRef = React.useRef();
countRef.current = count;
React.useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${countRef.current} times`);
}, 3000);
}, [count]);
This is actually persisted between renders.
This pattern can be useful if you need to know about the previous state of the the component.
const countRef = React.useRef();
let message = '';
if (countRef.current < count) message = 'Higher';
if (countRef.current > count) message = 'Lower';
countRef.current = count;
Let's add the ability to add and remove counters.
const [counters, setCounters] = useState([id(), id()]);
const addCounter = () => setCounters([...counters, id()]);
const removeCounter = () => setCounters(counters.slice(0, -1));
We'll update the component to look something like this:
<main className="Application">
{counters.map(id => (
<Counter id={id} key={id} />
))}
<section className="controls">
<button onClick={addCounter}>Add Counter</button>
<button onClick={removeCounter}>Remove</button>
</section>
</main>
What if we did something like this in the Counter
?
useEffect(() => {
const interval = setInterval(() => {
console.log({ id, count });
}, 3000);
}, [id, count]);
Hmm… that has some weird effects.
Let's do better.
useEffect(() => {
const interval = setInterval(() => {
console.log({ id, count });
}, 3000);
return () => {
clearInterval(interval);
};
}, [id, count]);