Home
šŸ–Œļø

Placeholder components

Letā€™s build some nice loading states in React using a placeholder context.
One of the coolest APIs Iā€™ve seen in recent history is the SwiftUI redacted modifier. In the following SwiftUI example, a RepoView component is rendered twice in a column layout. The left has an icon and some text while on the right is the same component with the redacted modifier: turning the images and text into rectangles which take up roughly the same amount of room as their original counterparts.
https://swiftwithmajid.com/2020/10/22/the-magic-of-redacted-modifier-in-swiftui/
https://swiftwithmajid.com/2020/10/22/the-magic-of-redacted-modifier-in-swiftui/
This is useful not only when ā€œredactingā€ information but also as a placeholder. In Notion, clicking ā€œAll updatesā€ has to do a bit of fetching. In the meantime, the user is presented with the following ā€œplaceholderā€ UI elements - even just briefly - to give the user expectations of the information they are about to see. A bit more pleasing to the eye than a spinner (and less jarring when the information actually appears).
We can implement a similar API with React. Consider some basic components for text and images - things that weā€™d like to turn into rectangles. The props these components take shouldnā€™t be too surprising to you: some content and colors mostly.
const Text = ({
  children,
  fontSize,
  color,
}) => {
  return (
    <span
      style={{
        fontFamily: "sans-serif",
        fontSize,
        color,
      }}
    >
      {children}
    </span>
  )
}

const Image = ({
  alt,
  src,
  size,
  borderRadius,
}) => {
  return (
    <img
      src={src}
      alt={alt}
      style={{
        borderRadius: borderRadius,
        width: size,
        height: size,
      }}
    />
  )
}
And maybe a couple flexbox components to help us with layout.
const Row = ({
  gap,
  alignItems = "flex-start",
  children,
}) => (
  <div
    style={{
      display: "flex",
      alignItems,
      gap,
    }}
  >
    {children}
  </div>
)

const Column = ({
  gap,
  alignItems = "flex-start",
  children,
}) => (
  <div
    style={{
      display: "flex",
      flexDirection: "column",
      alignItems,
      gap,
    }}
  >
    {children}
  </div>
)
Naturally these components can be composed to accomplish something similar to the RepoView component above.
const RepoView = ({
  name,
  description,
  imageUrl,
  imageAlt,
  stars,
}) => (
  <Row gap={12}>
    <Column alignItems="center" gap={4}>
      <Image
        src={imageUrl}
        alt={imageAlt}
        size={48}
        borderRadius={4}
      />
      <Text fontSize={20} color="orange">
        {stars}
      </Text>
    </Column>
    <Column gap={2}>
      <Text fontSize={20} color="#eee">
        {name}
      </Text>
      <Text fontSize={16} color="#ccc">
        <div style={{ maxWidth: 300 }}>
          {description}
        </div>
      </Text>
    </Column>
  </Row>
)
And we can display a couple of ā€˜em
<Column gap={20}>
  <RepoView
    name="98.css"
    description="A design system for building faithful recreations of old UIs"
    stars={6600}
    imageUrl="https://jdan.github.io/98.css/icon.png"
    imageAlt="a pixelated up arrow on a button"
  />
  <RepoView
    name="isomer"
    description="Simple isometric graphics library for HTML5 canvas"
    stars={2800}
    imageUrl="https://user-images.githubusercontent.com/287268/170883272-a93888fc-aa9e-43a8-b01a-b9e7edc9bd6b.png"
    imageAlt="three colored columns sitting atop a gray base"
  />
  <RepoView
    name="tota11y"
    description="An accessibility (a11y) visualization toolkit"
    stars={4900}
    imageUrl="https://khan.github.io/img/tota11y.png"
    imageAlt="white sunglasses on a rainbow background"
  />
</Column>
To turn these into ā€œplaceholdersā€ it might be appealing to add an isPlaceholder prop when necessary. We can certain use this to do what we want.
const Text = ({
  children,
  fontSize,
  color,
  // New prop
  isPlaceholder,
}) => {
  return (
    <span
      style={{
        fontFamily: "sans-serif",
        fontSize,
        color,
        // Placeholder styles
        ...(isPlaceholder
          ? {
              opacity: 0.5,
							borderRadius: 4,
              background: color,
              userSelect: "none",
              pointerEvents: "none",
            }
          : undefined),
      }}
    >
      {children}
    </span>
  )
}
But notice we would then need the composite components to be placeholders as well.
const RepoView = ({
  name,
  description,
  imageUrl,
  imageAlt,
  stars,
  // New prop
  isPlaceholder,
}) => (
  <Row gap={12}>
    <Column alignItems="center" gap={4}>
      <Image
				isPlaceholder={isPlaceholder}
        src={imageUrl}
        alt={imageAlt}
        size={48}
        borderRadius={4}
      />
      <Text
        isPlaceholder={isPlaceholder}
        fontSize={20}
        color="orange"
      >
        {stars}
      </Text>
    </Column>
    <Column gap={2}>
      <Text
        // My goodness how much more of this??????
				//
				// šŸ”„šŸ”„šŸ”„šŸ”„šŸ”„šŸ”„
        isPlaceholder={isPlaceholder}
        fontSize={20}
        color="#eee"
      >
        {name}
      </Text>
You can imagine this process repeating.
Instead of this ā€œprop-drillingā€ we can make use of React Context to access the placeholder information from an arbitrary ancestor.
Start by creating a new context at the top of our file. false corresponds to the default value: we are not a placeholder when the context is missing.
const PlaceholderContext =
  React.createContext(false)
We cam consume it from our base components with useContext.
const Text = ({
  children,
  fontSize,
  color,
}) => {
  // No props, only context!
  const isPlaceholder = React.useContext(
    PlaceholderContext
  )

  return (
    <span
      style={{
        fontFamily: "sans-serif",
        fontSize,
        color,
        ...(isPlaceholder
          ? {
              opacity: 0.6,
              borderRadius: 4,
              background: color,
              userSelect: "none",
              pointerEvents: "none",
            }
          : undefined),
      }}
    >
      {children}
    </span>
  )
}

// For images, we'll bail early and draw
// a rectangle
const Image = ({
  alt,
  src,
  size,
  borderRadius,
}) => {
  const isPlaceholder = React.useContext(
    PlaceholderContext
  )

  if (isPlaceholder) {
    return (
      <div
        style={{
          borderRadius: borderRadius,
          background: "#eee",
          width: size,
          height: size,
          opacity: 0.5,
        }}
      ></div>
    )
  }

  return (
    <img
      src={src}
      alt={alt}
      style={{
        borderRadius: borderRadius,
        width: size,
        height: size,
      }}
    />
  )
}
We then ā€œprovideā€ this context with PlaceholderContext.Provider.
<PlaceholderContext.Provider
  value={true}
>
  <Column gap={20}>
    <RepoView
      name="98.css"
      description="A design system for building faithful recreations of old UIs"
      stars={6600}
      imageUrl="https://jdan.github.io/98.css/icon.png"
      imageAlt="a pixelated up arrow on a button"
    />
    {/* ... */}
  </Column>
</PlaceholderContext.Provider>
Notably: The definition of RepoView has not changed. <RepoView> doesnā€™t provide or consume the context: it doesnā€™t even know about it! And thatā€™s the point.
Letā€™s see our placeholders in all their glory.
And since itā€™s all react, we can have our provider respond to state changes like any other node in our tree.
export default function Home() {
  const [isPlaceholder, setIsPlaceholder] =
    React.useState(false)

  return (
    <main
      style={{
        maxWidth: 550,
        margin: "80px auto",
      }}
    >
      <PlaceholderContext.Provider
        value={isPlaceholder}
      >
        <Column gap={20}>
          <button
            onClick={() =>
              setIsPlaceholder(
                !isPlaceholder
              )
            }
          >
            Toggle
          </button>

          <RepoView
            name="98.css"
            ...
          />
				</Column>
      </PlaceholderContext.Provider>
    </main>
  )
}
While (arguably) not as elegant as .redacted(...), I find wrapping the components you need with <PlaceholderContext.Provider> to be fairly inoffensive. We can of course abstract this away if we so desire.
const Redacted = ({ children }) => (
  <PlaceholderContext.Provider value={true}>
    {children}
  </PlaceholderContext.Provider>
)
And, finally, our loading state. We render actual RepoViews, we just do so under a ā€œIā€™m a placeholderā€ context.
const LoadingRepos = () => (
  <Redacted>
    <Column gap={20}>
      <RepoView
        name="This will be redacted"
        description="We can put whatever we want here!"
        stars="1111"
        imageUrl=""
        imageAlt=""
      />
      <RepoView
        name="Shorter"
        description="Let's make our descriptions feel more organic by giving them a variable length"
        stars="1111"
        imageUrl=""
        imageAlt=""
      />
    </Column>
  </Redacted>
)
On second thought, maybe you really do want your placeholders to all look the sameā€¦
On second thought, maybe you really do want your placeholders to all look the sameā€¦
Anyway, just an interesting use of context that I wanted to share.