I pretty much went over this post in the class 😄: https://blog.joshuakgoldberg.com/use-effect-pet-peeve/
-
-
Save JoshuaKGoldberg/045468e7ca49c4dca0b446f1c8727bd8 to your computer and use it in GitHub Desktop.
https://en.wikipedia.org/wiki/Single-responsibility_principle
Common definition: "a component should only do one thing". That definition more closely aligns to the Unix Philosophy but both are good advice.
"A component should only have one reason to change"
(I personally think of it as "one group of reasons to change")
As in, if a component has responsibility over multiple areas, needing to make changes to any of those areas would mean you have to change the component. Each area is a potential reason for the component to change.
"DRY WET KISS"es are good adjacent terms too:
- DRY (Don't Repeat Yourself): If you find yourself writing the same thing repeatedly, consider making something shared you can re-use
- WET (Write Everything Twice): Don't DRY before you're sure it's useful. Premature drying can be confusing and inconvenient.
- KISS (Keep It Simple, Stupid): Either way, try not to overcomplicate things. Prefer simple, easy-to-read code over convoluted shortcuts.
Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it. —Brian Kernighan
Suppose you have two pages, each of which have very similar layouts and styling:
function IndexPage() {
return (
<>
<header className={styles.header}>logo</header>
<main>
<h1>Hello, world!</h1>
<p>cool</p>
</main>
<footer>we're cool people</footer>
</>
);
}
function AboutPage() {
return (
<>
<header className={styles.header}>logo</header>
<main>
<h1>About us!</h1>
<p>cool</p>
</main>
<footer>we're cool people</footer>
</>
);
}
Both of these components have two areas of responsibility:
- They both individually set up the layout styles for the page
- Each also has some page contents to render
The repeated layout code is a lot of copy & paste (copypasta!) -- especially if we add more pages with the same layout. We're violating both DRY and SRP.
One common refactor to help with DRY and SRP is to cut-and-paste the shared code from components into a new component. You can have that new component take in anything different between the existing components as prop.
Here, we can make a Layout
component that sets up the layout elements and styles.
The only difference is the contents to render: children
.
function Layout({ children }) {
return (
<>
<header className={styles.header}>logo</header>
<main>{children}</main>
<footer>we're cool people</footer>
</>
);
}
function IndexPage() {
return (
<Layout>
<h1>Hello, world!</h1>
<p>cool</p>
</Layout>
);
}
function AboutPage() {
return (
<Layout>
<h1>About us!</h1>
<p>cool</p>
</Layout>
);
}
Much less code to read or write, and much less duplication. Yay!
Remember to WET: don't extract things that you're not sure are actually duplicated.
If we were to have, say, some weird different page component with a different structure, it wouldn't make sense to add complexity to Layout
.
// How would you have Layout support this?
// Extra props? Lots of logic? Eww...
function FancyMarketingPage() {
return (
<>
<header className={styles.minimalHeader}>fancy logo</header>
<main>
<h1 className={styles.fancy}>About us!</h1>
<p>cool</p>
</main>
<div className={styles.wide}>
<footer>we're cool people</footer>
</div>
</>
);
}
A question was asked about which is better:
- One big component with a bunch of elements that are only used in it
- Separate child components that are only used in that main component
There's no one-size-fits-all answer, but in general I prefer separating out child components for readability. That way you can skim through the big main component and see how it's all organized.
This HomePage
component could be hundreds of lines of JSX syntax if it wasn't split out.
function HomePage() {
return (
<MainLayout>
<Header />
<MainArea />
<MoreQuotes />
<ContactUs />
</MainLayout>
);
}
One downside to this strategy is if the child components each would need a lot of props. At that point it might be easier to just have the one big component.
But, maybe having so many interconnected props is a sign your component is too complex?
React's hooks are built wonderfully for extracting shared logic out of components. Hooks can take in parameters, allowing you to customize their behavior.
For example, take this UserDisplay
component that uses inline logic to fetch a user:
function UserDisplay() {
const [user, setUser] = useState();
useEffect(() => {
fetch("/my-user-endpoint").then(async (response) =>
setUser(await response.json())
);
}, []);
return user ? "loading..." : <div>{user.username}</div>;
}
What if you wanted to use that same logic to grab a user in multiple components? You could extract it into a hook! ✨
const useUser = () => {
const [user, setUser] = useState();
useEffect(() => {
fetch("/some-user-endpoint").then(async (response) =>
setUser(await response.json())
);
}, []);
return user;
};
function UserDisplay() {
const user = useUser();
return user ? "loading..." : <div>{user.username}</div>;
}
UserDisplay
is now much easier to read, and useUser
is a single-responsibility hook.
The same caveats apply from before: DRYing can be useful, but isn't always. Make sure the logic you want to extract actually is similar between uses.
Hooks can receive function parameters the same way components take in props. That means they can have behavior customized by each place that calls them.
Suppose UserDisplay
and other components each run some logic once user
is defined (and that logic is different in each component):
function UserDisplay() {
const user = useUser();
useEffect(() => {
if (user) {
// Do something with user
console.log("Hello!", user);
}
}, [user]);
return user ? "loading..." : <div>{user.username}</div>;
}
You could again DRY things up by writing a new hook, useUserEffect
, that takes in the logic to run with user as a parameter:
function useUserWithEffect(withUser) {
const user = useUser();
// repeated across different components
useEffect(() => {
if (user) {
withUser(user);
}
}, [user]);
return user;
}
function UserDisplay() {
const user = useUserWithEffect((loadedUser) => {
// Do something with user
console.log("Hello!", user);
});
return user ? "loading..." : <div>{user.username}</div>;
}
The same caveats apply from before: DRYing can be useful, but isn't always. Make sure the logic you want to extract actually is similar between uses. In this situation, I personally probably wouldn't extract things out, as there's not much added on top of
useUser
.
Prop drilling is the term for when you have to pass a prop through multiple higher-up components to where it's used in a lower-down component. It's common in many React apps. It especially common in apps where some state is initialized globally and used in many components.
For example, this Main
component sets up a userID
state that's only used lower down in the MyContents
component:
function Main() {
// TODO: whatever integration you actually use
const userId = localStorage.getItem("userId");
return <MyPage userID={userID} />;
}
function MyPage({ userId }) {
return (
<>
<MyHeader />
<MyContents userID={userID} />
<MyFooter />
</>
);
}
function MyContents({ userId }) {
const userId = useContext(UserIdContext);
return <div>my id is {userId}</div>;
}
Prop drilling is fine in small quantities. But, when you have a lot of components and/or a lot of props to drill, it can be a real pain.
React has a built-in solution to get around prop drilling: Context! React Contexts allow you to set up props that can be accessed by any descendent component, without explicitly prop drilling them down.
Contexts are done in three steps:
- Create a context object with the
createContext()
function - Render the context's provider as a component with a
value
prop somewhere in your applications - In a descendent component (underneath that provider), retrieve that
value
prop using theuseContext()
function
See the docs: https://beta.reactjs.org/apis/createcontext / https://beta.reactjs.org/apis/usecontext
We can rewrite the Main
application using context to avoid prop drilling:
import { createContext, useContext } from "react";
const UserIdContext = createContext();
function Main() {
// TODO: whatever integration you actually use
const userId = localStorage.getItem("userId");
return (
<UserIdContext.Provider value={userId}>
<MyPage />
</UserIdContext.Provider>
);
}
function MyPage() {
return (
<>
<MyHeader />
<MyContents />
<MyFooter />
</>
);
}
function MyContents() {
const userId = useContext(UserIdContext);
return <div>my id is {userId}</div>;
}
Context is great for removing prop drilling clutter. It does come with a little bit of overhead, though. Try not to use it unless you're sure setting it up is less overhead than prop drilling.
Someone in class asked about whether using prop drilling and/or context has downsides of coupling parts of the application together. Coupling can be bad because it makes the application less flexible to refactor later. In other words, does it force unrelated parts of the application to know about each other?
Yes, whatever solution you use will introduce coupling. Such is the inevitable pain of application development: there are always tradeoffs!
The nice thing about prop drilling is that it makes your application explicit about the dependencies of each component. Context is more implicit but doesn't bog down intermediate components with prop drilling.
Some applications choose to use an external state management solution such as Redux. Those are nice for separating parts of application state management from your React code (useful for some larger apps), but are themselves a coupled dependency!
No matter what you do, you'll introduce coupling.
If your MyContents
component needs a userId
, it's already conceptually coupled to that need.
We chatted about how many projects are calling API endpoints and need a good pattern for that in React. Fancy solutions such as Apollo are nice at scale, but can be complexity overkill when your application is small. And it's good to learn how to do this stuff yourself!
Let's apply the same ideas from earlier notes on extracting shared logic into something that can be reused.
Suppose your application frequently calls to some API endpoint and needs to store the result in state.
You could then write a hook like useApiQuery
that takes in the API endpoint as a parameter, and stores the result in state:
const useApiQuery = (endpoint) => {
const [result, setResult] = useState();
useEffect(() => {
setResult(undefined);
fetch(endpoint)
.then(async (response) => setResult(await response.json()))
.catch((error) => setResult(new Error(error)));
}, [endpoint]);
return result;
};
function FavoriteFruitDisplay() {
const fruit = useApiQuery("/api/favorite-fruit");
if (!fruit) {
return <div>loading...</div>;
}
if (fruit instanceof Error) {
return <div>Oh no: {fruit}</div>;
}
return <div>Favorite fruit: {fruit}</div>;
}
The FavoriteFruitDisplay
component doesn't concern itself with the intricacies of how to fetch from the endpoint.
It just knows it receives a fruit
data that's undefined
, an Error
, or an { ... }
object.
Many API endpoints require some kind of authentication.
We can use the Context feature from before to retrieve some shared authentication data in the app in our useApiQuery
hook.
// Presumably this is called by components that are descendents of:
// <UserContext.Provider value={{ userAuthToken, userId }}
/** @param {string} endpoint */
export const useApiQuery = (endpoint) => {
const { userAuthToken, userId } = useContext(UserContext);
const [result, setResult] = useState();
useEffect(() => {
setResult(undefined);
// (however you'd fetch to your endpoint)
fetch(endpoint, { headers: { userAuthToken, userId } })
.then(async (response) => setResult(await response.json()))
.catch((error) => setResult(new Error(error)));
}, [endpoint]);
return result;
};
It was asked in class if we can simplify around having to handle the data being undefined
or an Error
or an object.
That is a bit of annoying code to repeat.
Some applications combine the undefined
and Error
handling into one UI...
But that's not a great user experience for the latter.
You could also write a shared component that visualizes some data result. It could take in a render prop: a prop that happens to be a function that returns JSX syntax.
const ApiQuery = ({ endpoint, renderData }) => {
const data = useApiQuery(endpoint);
if (!data) {
return <div>loading...</div>;
}
if (data instanceof Error) {
return <div>Oh no: {data}</div>;
}
return renderData(data);
};
function FavoriteFruitsDisplay() {
return (
<ApiQuery
endpoint="/api/favorite-fruit"
renderData={(fruit) => <div>Favorite fruit: {fruit}</div>}
/>
);
}
So DRY!
I didn't mention this in the call, but fun fact: children
can be a render prop too!
A slightly different way to set up the ApiQuery
component could be:
const ApiQuery = ({ children, endpoint }) => {
const data = useApiQuery(endpoint);
if (!data) {
return <div>loading...</div>;
}
if (data instanceof Error) {
return <div>Oh no: {data}</div>;
}
return children(data);
};
function FavoriteFruitsDisplay() {
return (
<ApiQuery endpoint="/api/favorite-fruit">
{(fruit) => <div>Favorite fruit: {fruit}</div>}
</ApiQuery>
);
}
Fun challenge: you could turn
APIQuery
into a hook if you wanted to, likeuseAPIQueryRender
...
It was asked: is it better to have one big useEffect
with many deps (dependencies) arrays, or multiple useEffects
with minimally sided deps arrays?
// one big one with may deps (dependencies in the array)
useEffect(() => {
console.log(apple);
if (banana) {
console.warn(cherry);
}
}, [apple, banana, cherry]);
// individual useEffects with fewer deps
useEffect(() => {
console.log(apple);
}, [apple]);
useEffect(() => {
if (banana) {
console.warn(cherry);
}
}, [banana, cherry]);
It's a little situation-dependent but in general I prefer the latter: individual useEffect
s with fewer deps.
- It's easier to see each discrete area of logic
- React can re-run only the changed effect areas (e.g. not logging
apple
whenbanana
orcherry
change)
React is likely to get a new hook soon called useEvent
.
This is totally optional knowledge but the RFC has a very good explanation of difficulties with useEffect
: https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md
We talked a bit about choices for styling projects. If you're just working on a small project, Tailwind is great for getting started Tailwind is also great overall!
If you want to use a more complete solution (higher complexity, more built-in things):
- Bootstrap
- Chakra UI
- Material UI
- ...and others
I don't have a strong preference. All these things are very nice.
Trial and error is good: find out what you personally like, and make an informed decision.
Whatever you do:
- use some utility that does work for you (tailwind, material, etc)
- extract out a "design system"
A collection of shared constants and components
- Constants: colors, media query widths, etc.
- Components: button, paragraph, footer, etc.
Make standard good versions of things to be reused, and put them all in one place. A design system!
See Brad Frost's "Atomic Design": https://bradfrost.com/blog/post/atomic-web-design
Making a design system for a project means you'll spend much less time rewriting things. Less code to write -> good!