Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save JoshuaKGoldberg/045468e7ca49c4dca0b446f1c8727bd8 to your computer and use it in GitHub Desktop.
Save JoshuaKGoldberg/045468e7ca49c4dca0b446f1c8727bd8 to your computer and use it in GitHub Desktop.
Monterey Class Notes on React - 5/16/2022

Single Responsibility Principle (SRP)

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.

My Preferred Definition

"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.

A Few Related Terms

"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

Examples

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.

Extracting a Layout

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!

When Not to DRY

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>
    </>
  );
}

Big vs. Separated Components

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?

DRYing With Hooks

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.

DRYing Hooks

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 and Context

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.

Context

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:

  1. Create a context object with the createContext() function
  2. Render the context's provider as a component with a value prop somewhere in your applications
  3. In a descendent component (underneath that provider), retrieve that value prop using the useContext() 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.

Thoughts on Coupling

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.

API Hooks

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!

Shared API Hooks

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.

Shared User Auth in Hooks

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;
};

Shared Endpoint Visualization

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, like useAPIQueryRender...

Splitting useEffects

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 useEffects 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 when banana or cherry change)

useEvent

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

6. Styling

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"

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment