Conditional Rendering Example
function MessageItem(props) {
const { userName, userImg, text } = props.messageData;
const [isLiked, setIsLiked] = useState(false);
const handleClick = (event) => {
console.log("you liked " + userName + "'s post!");
setIsLiked(!isLiked); //toggle
}
let heartColor = "grey";
if (isLiked) {
heartColor = "red";
}
return (
<div className="message d-flex mb-3">
<div className="me-2">
<img src={userImg} alt={userName + "'s avatar"} />
</div>
<div className="flex-grow-1">
<p className="user-name">{userName}</p>
<p>{text}</p>
<button className="btn like-button" onClick={handleClick}>
<span className="material-icons" style={{ color: heartColor }}>favorite_border</span>
</button>
{ isLiked &&
<div>
<p> Here's some more text if the 'liked' button is selected!</p>
<img src={userImg} alt={userName + "'s avatar"} />
</div>
}
</div>
</div>
)
}
Example 2: If isLiked, then show that extra div section
Example 1: If isLiked, change the color on the isLiked button
How do we make a filter option that uses checkboxes?
export function ChatPane(props) {
const [parrotFilter, setParrotFilter] = useState(false);
const currentChannel = props.currentChannel;
const chatMessages = props.chatMessages;
...
const parrotFilteredMessages = channelMessages.filter((msgObj) => {
if (!parrotFilter)
return (true);
else
return (parrotFilter && msgObj.userName === "Parrot");
})
const handleChange = (event) => {
setParrotFilter(!parrotFilter);
}
const messageItemArray = parrotFilteredMessages.map((messageObj) => {
const element = (
<MessageItem
messageData={messageObj}
key={messageObj.timestamp}
/>
)
return element;
})
...
return (
<div className="scrollable-pane">
<label>
<input type="checkbox" id="parrotFilter" name="parrotFilter" onChange={handleChange} />
{" Parrot Filter"}
</label>
<div className="pt-2 my-2">
{messageItemArray}
</div>
</div>
)
}
state var
if filter is checked
then filter for "Parrot"
filter input element
function MessageItem(props) {
const { userName, userImg, text } = props.messageData;
//state
const [isLiked, setIsLiked] = useState(false);
const handleClick = (event) => {
console.log("you liked " + userName + "'s post!");
setIsLiked(!isLiked); //toggle
}
//RENDERING
let heartColor = "grey";
if (isLiked) {
heartColor = "red";
}
return (
<div className="message d-flex mb-3">
<div className="me-2">
<img src={userImg} alt={userName + "'s avatar"} />
</div>
<div className="flex-grow-1">
<p className="user-name">{userName}</p>
<p>{text}</p>
<button className="btn like-button" onClick={handleClick}>
<span className="material-icons" style={{ color: heartColor }}>favorite_border</span>
</button>
</div>
</div>
)
}
state variable, and setter function
setState when clicked (toggles)
Allows us to change the look when rendered
style attribute is applied
We add user interaction in React the same way as with the DOM: by listening for events and executing callback functions when they occur.
special React prop
can only put listeners on HTML
elements, not Components!
function MyButton(props) {
//A function that will be called when clicked
//The name is conventional, but arbitrary.
//The callback will be passed the DOM event as usual
const handleClick = (event) => {
console.log("clicky clicky");
}
//make a button with an `onClick` attribute!
//this "registers" the listener and sets the callback
return <button onClick={handleClick}>Click me!</button>;
}
You add state to a component by using a state hook. The hook defines a "state variable" which will retain its value across Component function calls, as well as a function to update that variable.
//import the state hook function `useState()` to define state
import React, { useState } from 'react';
function CountingButton(props) {
const [count, setCount] = useState(0);
const handleClick = (event) => {
setCount(count+1); //update the state to be a new value
//and RE-RENDER the Component!
}
return (
<button onClick={handleClick}>Clicked {count} times</button>
);
}
state variable
update function
initial value for variable
initial value for variable
import React, { useState } from 'react';
export function ComposeForm(props) {
const [typedValue, setTypedValue] = useState("");
const handleChange = (event) => {
const inputtedValue = event.target.value;
setTypedValue(inputtedValue); //update state and re-render!
}
const handleSubmit = (event) => {
event.preventDefault();
console.log("submitting", typedValue);
const userObj = { userId: "parrot", userName: "Parrot", userImg: "/img/Penguin.png" }
props.addMessageCallback(userObj, typedValue, "general")
setTypedValue(""); //empty the input!
}
return (
<form className="my-2" onSubmit={handleSubmit}>
<div className="input-group">
<textarea className="form-control" rows="2"
placeholder="Type a new message"
onChange={handleChange} value={typedValue}></textarea>
<button className="btn btn-secondary" type="submit">
<span className="material-icons">send</span>
</button>
</div>
</form>
);
}
add 'value' key for controlled form
add 'onChange' key for controlled form
state variable to keep track
of text typed into the form
update state var with value we get back in the event prop
prevent default
import React, { useState } from 'react';
export function ComposeForm(props) {
const [typedValue, setTypedValue] = useState("");
const handleChange = (event) => {
const inputtedValue = event.target.value;
setTypedValue(inputtedValue); //update state and re-render!
}
const handleSubmit = (event) => {
event.preventDefault();
console.log("submitting", typedValue);
const userObj = { userId: "parrot", userName: "Parrot", userImg: "/img/Penguin.png" }
props.addMessageCallback(userObj, typedValue, "general")
setTypedValue(""); //empty the input!
}
return (
<form className="my-2" onSubmit={handleSubmit}>
<div className="input-group">
<textarea className="form-control" rows="2"
placeholder="Type a new message"
onChange={handleChange} value={typedValue}></textarea>
<button className="btn btn-secondary" type="submit">
<span className="material-icons">send</span>
</button>
</div>
</form>
);
}
event handler when 'submit' is clicked
use the typedValue to create object that you can add to the message array stored in App
empty the state var
props are for information that doesn’t change from the Component’s perspective, including “initial” data. state is for information that will change, usually due to user interaction (see React FAQ).
If multiple components rely on the same
data (variable), you should "lift up" that
state
to a shared parent, who can pass the information back down as
props
.
ChildA
ChildB
Parent
Has Data
(state)
Needs Data
<ChildA data={data} />
Has Data (prop)
Has Data (prop)
<ChildB data={data} />
Has Data (state)
ComposeForm
App
< ComposeForm
addMessageCallback={addMessage} />
ChatPane
< ChatPane
currentChannel={currentChannel}
chatMessages={chatMessages} />
export default function App(props {
const [chatMessages, setChatMessages] = useState(CHAT_HISTORY);
const addMessage = (text) => {
const newMessage = { "userId": "penguin", "text": text, "timestamp": Date.now(), etc }
const updateChatMessages = [...chatMessages, newMessage];
setChatMessages(updateChatMessages);
}
...
chat state elevated to the App
pass down addMessage prop
pass down chatMessage prop
addMessage implemented in the App
To allow child components to "update" the parent's state, pass them a callback function as a prop.
Style Guide: do not pass a state setter function directly.
function App(props) {
const [data, setData] = useState([]);
const addItemToData = (newItem) => {
const newData = [...data, newItem]; //copy via spread
setData(newData); //update state
}
return (
<FruitButton callback={addItemToData} text={"Apple"} />
<FruitButton callback={addItemToData} text={"Banana"} />
)
}
function FruitButton(props) {
//what to do when clicked
const handleClick = (event) => {
//call given callback, passing in given text
props.callback(props.text);
}
return (
<button onClick={handleClick}>{props.text}</button>
)
}
State variables will only be updated if a different value is passed to the setter function. For arrays and objects, pass a copy of the element with an updated element or property.
function TodoListWithError(props) {
//a state value that is an array of objects
const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
const handleClick = (event) => {
todos[0].text = "Fix bugs"; //modify the object
//but don't make a new one
setTodos(todos) //This won't work! Not "changing"
}
//...
}
function TodoList(props) {
//a state value that is an array of objects
const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
const handleClick = (event) => {
//create a copy of the array using the `map()` function
const todosCopy = todos.map((todoObject, index) => {
const objCopy = {...todoObject}; //copy object as well
if(index == 0) { //transform objects if needed
objCopy.text = "Fix bugs"
}
return objCopy; //return object to go into new array
})
setTodos(todosCopy) //This works!
}
//...
}
export default function App(props) {
const [chatMessages, setChatMessages] = useState(CHAT_HISTORY);
const channelList = [
'general', 'random', 'dank-memes', 'channel-4', 'pet-pictures']
const currentChannel = "general";
const addMessage = (userObj, messageText, channel) => {
const newMessage = {
"userId": userObj.userId,
"userName": userObj.userName,
"userImg": userObj.userImg,
"text": messageText,
"timestamp": Date.now(),
"channel": channel
}
////this below won't work because even though the new message to the
//end of the array and call setState, react doesn't know that
//the array has changed and doesn't rerender
// chatMessages.push(newMessage);
// setChatMessages(chatMessages); //update state and re-render
// console.log(chatMessages)
const updateChatMessages = [...chatMessages, newMessage];
setChatMessages(updateChatMessages); //update state and re-render
}
Note I create a new temporary array to pass to the setState variable
const DEFAULT_USERS = [
{userId: null, userName: null, userImg: '/img/null.png'}, //null user
{userId: "penguin", userName: "Penguin", userImg: '/img/Penguin.png'},
{userId: "parrot", userName: "Parrot", userImg: '/img/Parrot.png'},
{userId: "zebra", userName: "Zebra", userImg: '/img/Zebra.png'},
]
export function HeaderBar(props) {
const handleClick = (event) => {
const whichUser = event.currentTarget.name //access button, not image
console.log(whichUser);
const selectedUserObj = DEFAULT_USERS.filter((userObj) => userObj.userId === whichUser)[0]
|| DEFAULT_USERS[0] //null user if not found
}
const userButtons = DEFAULT_USERS.map((userObj) => {
return (
<button className="btn user-icon" key={userObj.userName}
name={userObj.userId} onClick={handleClick}
>
<img src={userObj.userImg} alt={userObj.userName + " avatar"} />
</button>
)
})
return (
<header className="text-light bg-primary px-1 d-flex justify-content-between">
<h1>React Messenger</h1>
<div>
{userButtons}
</div>
</header>
)
}
Array of users
transform user obj array to array of buttons
click event if user button clicked
skeleton callback when clicked
export default function App(props) {
//state
const [chatMessages, setChatMessages] = useState(CHAT_HISTORY);
const [currentUser, setCurrentUser] =
useState({userId: null, userName: null, userImg: '/img/null.png'});
//initialize as null user
const channelList = [
'general', 'random', 'dank-memes', 'channel-4', 'pet-pictures'
]
const currentChannel = "general";
...
//what content should my App look like?
return (
<div className="container-fluid d-flex flex-column">
<HeaderBar currentUser={currentUser}/>
<div className="row flex-grow-1">
<div className="col-3">
<ChannelList channels={channelList} currentChannel={currentChannel} />
</div>
<div className="col d-flex flex-column">
<ChatPane chatMessages={chatMessages} currentChannel={currentChannel} />
<ComposeForm currentUser={currentUser} addMessageCallback={addMessage} />
</div>
</div>
</div>
);
}
State var for current user initialized to null user
pass down current user
export function HeaderBar(props) {
const currentUser = props.currentUser;
const handleClick = (event) => {
const whichUser = event.currentTarget.name //access button, not image
console.log(whichUser);
const selectedUserObj = DEFAULT_USERS.filter((userObj) => userObj.userId === whichUser)[0] || DEFAULT_USERS[0] //null user if not found
}
const userButtons = DEFAULT_USERS.map((userObj) => {
//if currently logged in
let classList = "btn user-icon";
if (userObj.userId === currentUser.userId) {
classList = "btn user-icon highlighted"
}
return (
<button className={classList} key={userObj.userName}
name={userObj.userId} onClick={handleClick}
>
<img src={userObj.userImg} alt={userObj.userName + " avatar"} />
</button>
)
})
return (
<header className="text-light bg-primary px-1 d-flex justify-content-between">
<h1>React Messenger</h1>
<div>
{userButtons}
</div>
</header>
)
}
Get current user from prop
style current user button differently
export function ComposeForm(props) {
const [typedValue, setTypedValue] = useState("");
const currentUser = props.currentUser;
const handleChange = (event) => {
const inputtedValue = event.target.value;
setTypedValue(inputtedValue); //update state and re-render!
}
const handleSubmit = (event) => {
event.preventDefault();
console.log("submitting", typedValue);
const userObj = { userId: "parrot", userName: "Parrot", userImg: "/img/Penguin.png" }
props.addMessageCallback(userObj, typedValue, "general")
setTypedValue(""); //empty the input!
}
return (
<form className="my-2" onSubmit={handleSubmit}>
<div className="input-group">
{<img src={currentUser.userImg} alt={currentUser.userName + " avatar"} />}
<textarea className="form-control" rows="2"
placeholder="Type a new message"
onChange={handleChange} value={typedValue}></textarea>
<button className="btn btn-secondary" type="submit">
<span className="material-icons">send</span>
</button>
</div>
</form>
);
}
Get current user from prop
use current user pic in control
export default function App(props) {
...
const [currentUser, setCurrentUser] =
useState({userId: null, userName: null, userImg: '/img/null.png'});
...
const loginUser = (userObj) => {
console.log("logging in as: ", userObj.userName);
setCurrentUser(userObj);
}
return (
<div className="container-fluid d-flex flex-column">
<HeaderBar currentUser={currentUser} loginUserCallback={loginUser}/>
<div className="row flex-grow-1">
<div className="col-3">
<ChannelList channels={channelList} currentChannel={currentChannel} />
</div>
<div className="col d-flex flex-column">
<ChatPane chatMessages={chatMessages} currentChannel={currentChannel} />
<ComposeForm currentUser={currentUser} addMessageCallback={addMessage} />
</div>
</div>
</div>
);
}
function to update user in App
Pass the callback to Header as a callback
export function HeaderBar(props) {
const currentUser = props.currentUser;
const loginFunction = props.loginUserCallback;
const handleClick = (event) => {
const whichUser = event.currentTarget.name //access button, not image
console.log(whichUser);
const selectedUserObj = DEFAULT_USERS.filter((userObj) => userObj.userId === whichUser)[0] || DEFAULT_USERS[0] //null user if not found
console.log(selectedUserObj);
loginFunction(selectedUserObj);
}
...
return (
<header className="text-light bg-primary px-1 d-flex justify-content-between">
<h1>React Messenger</h1>
<div>
{userButtons}
</div>
</header>
)
}
get update function from prop
Update when the user clicks on button
export function ComposeForm(props) {
const [typedValue, setTypedValue] = useState("");
const currentUser = props.currentUser;
const handleChange = (event) => {
const inputtedValue = event.target.value;
setTypedValue(inputtedValue); //update state and re-render!
}
const handleSubmit = (event) => {
event.preventDefault();
console.log("submitting", typedValue);
// const userObj = { userId: "parrot", userName: "Parrot", userImg: "/img/Penguin.png" }
props.addMessageCallback(currentUser, typedValue, "general")
setTypedValue(""); //empty the input!
}
return (
<form className="my-2" onSubmit={handleSubmit}>
<div className="input-group">
{<img src={currentUser.userImg} alt={currentUser.userName + " avatar"} />}
<textarea className="form-control" rows="2"
placeholder="Type a new message"
onChange={handleChange} value={typedValue}></textarea>
<button className="btn btn-secondary" type="submit">
<span className="material-icons">send</span>
</button>
</div>
</form>
);
}
Use currentUser rather than hack temporary object
export default function App(props) {
const [chatMessages, setChatMessages] = useState(CHAT_HISTORY);
const [currentUser, setCurrentUser] =
useState({userId: null, userName: null, userImg: '/img/null.png'});
const channelList = [
'general', 'random', 'dank-memes', 'channel-4', 'pet-pictures'
]
const currentChannel = "general";
const addMessage = ( messageText) => {
const userObj = currentUser;
const newMessage = {
"userId": userObj.userId,
"userName": userObj.userName,
"userImg": userObj.userImg,
"text": messageText,
"timestamp": Date.now(),
"channel": currentChannel
}
...
}
Use state vars. Don't need the caller to provide currentUser or current Channel
export function ComposeForm(props) {
...
const handleSubmit = (event) => {
event.preventDefault();
// const userObj = { userId: "parrot", userName: "Parrot", userImg: "/img/Penguin.png" }
props.addMessageCallback(typedValue)
setTypedValue(""); //empty the input!
}
Only need to provide text arg
To access the
value
in an
<input>
, save that value in the
state
(and update it
onChange
). This is called a controlled form.
use DOM event to refer to which input element
function MyInput(props) {
const [inputValue, setInputValue] = useState('')
const handleChange = (event) => {
let newValue = event.target.value
setInputValue(newValue);
}
return (
<div>
<input type="text" onChange={handleChange} value={inputValue} />
You typed: {value}
</div>);
)
}
React components are structured to be self-contained, re-usable elements... so there are lots of pre-defined components online you can use!
In order to use a component in your app:
npm install lib-name
import { ComponentName } from 'lib-name'
<ComponentName />
Much of the web (and software in general) is built on shared, reusable libraries.
Bootstrap style Dropdowns
//Slide 29, User the callback to swap users
import React from 'react';
import Dropdown from 'react-bootstrap/Dropdown';
...
const userButtons = DEFAULT_USERS.map((userObj) => {
let classList = "btn user-icon";
return (
<Dropdown.Item className={classList} key={userObj.userName}
name={userObj.userId} onClick={handleClick}
>
<img src={userObj.userImg} alt={userObj.userName + " avatar"} />
{userObj.userName}
</Dropdown.Item>
)
})
return (
<header className="text-light bg-primary px-1 d-flex justify-content-between">
<h1>React Messenger</h1>
<Dropdown>
<Dropdown.Toggle variant="primary">
<img src={currentUser.userImg} alt={currentUser.userName + " avatar"} />
</Dropdown.Toggle>
<Dropdown.Menu>
{/* <Dropdown.Item href="#/action-1">Action</Dropdown.Item>
<Dropdown.Item href="#/action-2">Another action</Dropdown.Item>
<Dropdown.Item href="#/action-3">Something else</Dropdown.Item> */}
{userButtons}
</Dropdown.Menu>
</Dropdown>
</header>
)
}
import react-bootstrap
transform json user objects into Dropdown.Items
add current user to DropDown.Toggle
Make Channel Changing interactive (app changes)
//Slide 30, User the callback to swap users
import React, { useState } from 'react';
...
export default function App(props) {
const [chatMessages, setChatMessages] = useState(CHAT_HISTORY);
const [currentUser, setCurrentUser] =
useState({userId: null, userName: null, userImg: '/img/null.png'});
const channelList = [
'general', 'random', 'dank-memes', 'channel-4', 'pet-pictures'
]
// const currentChannel = "general";
const [currentChannel, setCurrentChannel] = useState("general")
const updateCurrentChannel = (channelName) => {
setCurrentChannel(channelName);
}
...
return (
<div className="container-fluid d-flex flex-column">
<HeaderBar currentUser={currentUser} loginUserCallback={loginUser}/>
<div className="row flex-grow-1">
<div className="col-3">
<ChannelList channels={channelList} updateCurrentChannel={updateCurrentChannel} currentChannel={currentChannel} />
</div>
<div className="col d-flex flex-column">
<ChatPane chatMessages={chatMessages} currentChannel={currentChannel} />
<ComposeForm currentUser={currentUser} addMessageCallback={addMessage} />
</div>
</div>
</div>
);
}
currentChannel as state var
function to update current channel
pass updater function as a prop to ChannelList
Make Channel Changing interactive (ChannelList changes)
/ Slide 31 Make channelist changing interactive
import React from 'react';
export function ChannelList(props) {
const channels = props.channels;
const currentChannel = props.currentChannel;
const handleClick = (event) => {
// console.log("event: ", event)
console.log("event.textContent ", event.target.value)
props.updateCurrentChannel(event.target.value);
}
const linkElemArray = channels.map((channelNameString) => {
let classList = "btn btn-sm btn-outline-light my-1";
if(channelNameString === currentChannel) {
classList = "btn btn-sm btn-warning"
}
const element = (
<div key={channelNameString} >
<button className={classList} value={channelNameString} onClick={handleClick}>
{channelNameString}</button>
</div>
)
return element;
})
click handler function
call updater which was passed down as prop
register onClick
Find the count of "Liked Messages"
//Slide 32 - Find the count of liked messages from the data of objects
...
export function ChatPane(props) {
...
const channelMessages = chatMessages
.filter((msgObj) => {
return msgObj.channel === currentChannel;
})
.sort((a,b) => b.timestamp - a.timestamp);
const countOfLikedMessages = channelMessages.filter((msgObj) => {
return msgObj.liked === true;
}).length
...
return (
<div className="scrollable-pane">
<p>Number of liked messages: {countOfLikedMessages}</p>
<div className="pt-2 my-2">
{messageItemArray}
</div>
</div>
)
}
find # of liked from channelMessages
display the count
But MessageItem has local state variable for isLiked
//Slide 33 - But the 'liked' state here is local to the MessageItem Component
...
function MessageItem(props) {
const {userName, userImg, text, liked} = props.messageData;
//local state
const [isLiked, setIsLiked] = useState(liked);
...
let heartColor = "grey";
if(isLiked) {
heartColor = "red";
}
return (
<div className="message d-flex mb-3">
<div className="me-2">
<img src={userImg} alt={userName+"'s avatar"} />
</div>
<div className="flex-grow-1">
<p className="user-name">{userName}</p>
<p>{text}</p>
<button className="btn like-button" onClick={handleClick}>
<span className="material-icons" style={{ color: heartColor }}>favorite_border</span>
</button>
</div>
</div>
)
}
But this liked state variable is local to the MessageItem component
Make the data in App (parent) be the owner
//Slide 34 - Add a setter to toggle isLiked in the data of the Parent. Then pass
// the setter down to as a prop to the eventHandler rather than setting local state variable
export default function App(props) {
const [chatMessages, setChatMessages] = useState(CHAT_HISTORY);
...
const toggleIsLiked = (timestamp) => {
const copyOfChatMessages = chatMessages.map((chatObj) => {
if (chatObj.timestamp === timestamp)
return {...chatObj, "liked": !chatObj.liked}
else
return {...chatObj}
} )
setChatMessages(copyOfChatMessages);
}
return (
<div className="container-fluid d-flex flex-column">
<HeaderBar currentUser={currentUser} loginUserCallback={loginUser}/>
<div className="row flex-grow-1">
<div className="col-3">
<ChannelList channels={channelList} updateCurrentChannel={updateCurrentChannel} currentChannel={currentChannel} />
</div>
<div className="col d-flex flex-column">
<ChatPane chatMessages={chatMessages} currentChannel={currentChannel} toggleIsLikedCallback={toggleIsLiked} />
<ComposeForm currentUser={currentUser} addMessageCallback={addMessage} />
</div>
</div>
</div>
);
}
create toggler/setter in App
pass the toggler/setter as prop
Pass the toggler/setter prop down
//Slide 35/36 - Pass the setter/toggler down to the component that has the event handler
import React, { useState } from 'react';
export function ChatPane(props) {
const {currentChannel, chatMessages, toggleIsLikedCallback} = props;
const channelMessages = chatMessages
.filter((msgObj) => {
return msgObj.channel === currentChannel;
})
.sort((a,b) => b.timestamp - a.timestamp);
const countOfLikedMessages = channelMessages.filter((msgObj) => {
return msgObj.liked === true;
}).length
const messageItemArray = channelMessages.map((messageObj) => {
const element = (
<MessageItem
messageData={messageObj}
toggleIsLikedCallback={toggleIsLikedCallback}
key={messageObj.timestamp}
/>
)
return element;
})
get the prop
pass it down again to prop
Replace the logic for local state variable
//Slide 35/36 - Replace the local state variable with the setter to just update
// the status of the data in the data array held in the App ... which is the parent
unction MessageItem(props) {
const {userName, userImg, text, liked, timestamp} = props.messageData;
const toggleIsLikedCallback = props.toggleIsLikedCallback;
// const [isLiked, setIsLiked] = useState(liked);
const handleClick = (event) => {
toggleIsLikedCallback(timestamp)
// setIsLiked(!isLiked); //toggle
}
...
return (
<div className="message d-flex mb-3">
<div className="me-2">
<img src={userImg} alt={userName+"'s avatar"} />
</div>
<div className="flex-grow-1">
<p className="user-name">{userName}</p>
<p>{text}</p>
<button className="btn like-button" onClick={handleClick}>
<span className="material-icons" style={{ color: heartColor }}>favorite_border</span>
</button>
</div>
</div>
)
}
instead update the data in the parent (app) which will cause rerender
get the prop
remove the local state var
Review Ch 15-16: React & Interactive React
Read Ch 17: Client-Side Routing
Problem Set 07 due Thursday (2 days)
Treat this as a hard deadline!
Project Draft 2 due Friday (3 days)
This is a hard deadline.
Next time: Router (multiple pages & navigation!)