React Component Design
February 26, 2020
- Considerations for deciding on components & state
- Practice designing a React app
- Compare different patterns for writing components
The goals of this lecture are to:
- Understand different considerations when deciding on components and state
- Practice designing a React application
- Compare different patterns for writing components
Designing Components and State
Designing React components is a skill that takes time and repitition to develop.
Components
Generally speaking, React components should be small and do one thing.
By limiting the scope of functionality, we increase the chance that they can be used elsewhere in our application.
Take for example a component that displays a specific todo task. This component could be used across many types of lists, not just in the context of a todo list.
"Dumb" Components
Often, small components with limited functionality are so simple that they don't require any state.
These components are also referred to as presentational components.
In the code snippet below, our TodoItem
component does nothing but display a property that is sent in via props:
function TodoItem(props) {
return <div className="TodoItem">{ props.task }</div>;
}
A level up, in the containing component where our TodoItem
lives, we then pass to it the only information it needs (task
) via a prop:
function TodoList() {
// ... lots missing
return (
<div className="TodoList">
<TodoItem task={ todos[0] } />
<TodoItem task={ todos[1] } />
<TodoItem task={ todos[2] } />
</div>
);
}
💡Though we refer to them as "dumb" components, these presentational components are ideal. Remember: we like predictable, easy-to-debug code.
By limiting the state of components to only what is absolutely necessary, and only using presentational components otherwise, if problems arise, we can often root out the source of the problem much faster.Don't Store Derived Information
When we use the term "derived information", we mean information that can be caluclated from the data we alreay have.
For example, take a look at the following component:
function TodoList() {
const [todos, setTodos] = useState(["wash car", "wash cat"]);
const [numTodos, setNumTodos] = useState(2);
return (
<div>
You have {numTodos} tasks ...
</div>
);
}
Rather than adding to the complexity of our component with the additional state overhead of numTodos
, it would be a better choice to simply access todos.length
.
Resist the urge to over-engineer and keep your components as simple as possible.
Example: Dice Game
Let's work through designing an example application.
Say we are building a dice-rolling game for a casino.
Our game will feature 6 dice, each with a value between 1 and 20. When the user clicks the "Roll" button, each die will be assigned a new value.
In the root of our application, in App.js
, we'll place our game component, <Dice />
:
<Dice />
But, say it's the case that we'd like to have a concurrent game appear below, this time with only 4 dice and with each die having a maximum of 6 rather than 20.
How might we refactor our existing component to allow that?
A good place to start is by identifying the properties that we'd like to be customizable. In this case, numDice
, title
(of the game) and the maxVal
.
Our resultant JSX would look like the following:
<div className="App">
<Dice />
<Dice numDice={4} title="Mini Dice" maxVal={6} />
</div>
Design Considerations
Now that we've gotten a high level view of what our application will do, there are a series of questions we need ask ourselves as we dive into the code:
- What components do we need?
- What props does each component need?
- What state does each component need?
Component
From our planning above, we identified that the Dice component needs three props: numDice
, title
, and maxVal
.
Within our <Dice />
component, our state will hold the dice that will be displayed. We will create a nested component called <Die />
for each item in the dice array.
Last but not least, we know that when a user clicks on the "Roll" button, the state of the dice will change. This onClick
event will require a corresponding click handler.
Component
As mentioned above, we've decided there will be one <Die />
component for each die within our <Dice />
component's state.
The only thing that the <Die />
cares about is the number it must display, which we'll pass in via a prop.
Other than that, there is no other functionality required for this component!
Patterns for Writing Components
Let's take a look at a few common patterns to keep in mind when writing components.
Destructuring
You've seen how helpful ES6 destructuring can be with regard to keeping our code clean and concise. Using destructuring within our components is no different.
Up until this point, we've been using props
in the following way:
function Dice(props) {
// we reference props via
// props.title, props.numDice, props.maxVal
}
But, assuming there aren't an exceessive number of props being passed, we can use destructuring to avoid having to access each property off of the props
object:
function Dice({ title, numDice, maxVal }) {
// we reference props via
// title, numDice, maxVal
}
What's more, by using destructuring with the props
that are passed in, we can easily set default values to each.
Up until this point, we've assigned default properties using defaultProps
:
function Dice(props) {
// ... lots missing
}
Dice.defaultProps = {
title: "Main Game",
numDice: 6,
maxVal: 20
};
But, we can just as easily accomplish the same thing using default values:
function Dice({ title = "Main Game", numDice = 6, maxVal = 20 }) {
// ... lots missing
}
Arrow Functions
Components are just functions, so we can choose to write them as arrow functions if we choose.
If a component immediately renders, rather than using the function
keyword, as we've been doing in the past:
function Die(props) {
return <div className="Die">{props.value}</div>;
}
we can instead take advantage of the arrow function's implicit return, refactoring our function to look like the following:
const Die = ({ value }) => (
<div className="Die">{value}</div>
);
Keep in mind that it's not the case that you should just blindly use arrow functions for everything.
You'll see arrow functions used throughout documentation and code examples, but whatever direction you decide to go, the most important part is to stay consistent with the pattern you choose.