Bringing Back

Progressive Enhancement

Kent C. Dodds

Let's wake up

Your brain needs this 🧠

What this talk is

  • An explanation of Progressive Enhancement
  • A demonstrations of what it can do for us
  • A mic-drop moment 🎤

What this talk is not

  • JS hate-town

Let's
Get
STARTED!

Why was web dev so much easier before?

What changed?

Progressive Enhancement

if (navigator.geolocation) {
  // use geolocation API
}

Progressive Enhancement

  • Basic content should be accessible to all web browsers.

  • Basic functionality should be accessible to all web browsers.

  • Sparse, semantic markup contains all content.

  • Enhanced layout is provided by externally linked CSS.

  • Enhanced behavior is provided by externally linked JavaScript.

  • End-user web browser preferences are respected.

Enabling

Enhancing

<

An example of the status quo

without JS???

Who remembers?

  • Built with Remix
  • Backend persistence
  • User Auth
  • Progressively enhanced
  • 15% client-side JS

JS does not enable the experience

Built-in Mutations

<form method="post" action="/todos" enctype="application/x-www-form-urlencoded">
  <input type="hidden" name="todoId" value="cl7yrv54a0045j1dz7etu1u9z" />
  <input type="hidden" name="complete" value="true" />
  <button
    type="submit"
    name="intent"
    value="toggleTodo"
    class="toggle"
    title="Mark as complete"
  >
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="40"
      height="40"
      viewBox="-3 -3 105 105"
    >
      <circle
        cx="50"
        cy="50"
        r="50"
        fill="none"
        stroke="#ededed"
        stroke-width="3"
      ></circle>
    </svg>
  </button>
</form>

Why was web dev so much easier before?

What changed?

Guess the state management lib

The Mental Model

MPA mental model

SPA mental model

>

MPA capabilities

SPA capabilities

<

MPA

mental model

SPA

capabilities

+

Dev Experience

User Experience

There is no application state management with Remix.

Even if you are building Figma!

Even if you are building Figma!

There is no application state management with Remix.

Browser Pending UI

Progressively Unhancement 😬

function ListItem({ todo, filter }: { todo: TodoItem; filter: Filter }) {
  const updateFetcher = useFetcher();
  const toggleFetcher = useFetcher();
  const deleteFetcher = useFetcher();
  const updateFormRef = React.useRef<HTMLFormElement>(null);

  const complete = todo.complete;

  const shouldRender =
    filter === "all" ||
    (filter === "complete" && complete) ||
    (filter === "active" && !complete);

  if (!shouldRender) return null;

  return (
    <li className={complete ? "completed" : ""}>
      <div className="view">
        <toggleFetcher.Form method="post">
          <input type="hidden" name="todoId" value={todo.id} />
          <input type="hidden" name="complete" value={(!complete).toString()} />
          <button
            type="submit"
            name="intent"
            value="toggleTodo"
            className="toggle"
            title={complete ? "Mark as incomplete" : "Mark as complete"}
          >
            {complete ? <CompleteIcon /> : <IncompleteIcon />}
          </button>
        </toggleFetcher.Form>
        <updateFetcher.Form
          method="post"
          className="update-form"
          ref={updateFormRef}
        >
          <input type="hidden" name="intent" value="updateTodo" />
          <input type="hidden" name="todoId" value={todo.id} />
          <input
            name="title"
            className="edit-input"
            defaultValue={todo.title}
            onBlur={(e) => {
              if (todo.title !== e.currentTarget.value) {
                updateFetcher.submit(e.currentTarget.form);
              }
            }}
            aria-invalid={updateFetcher.data?.error ? true : undefined}
            aria-describedby={`todo-update-error-${todo.id}`}
          />
          {updateFetcher.data?.error && updateFetcher.state !== "submitting" ? (
            <div
              className="error todo-update-error"
              id={`todo-update-error-${todo.id}`}
            >
              {updateFetcher.data?.error}
            </div>
          ) : null}
        </updateFetcher.Form>
        <deleteFetcher.Form method="post">
          <input type="hidden" name="todoId" value={todo.id} />
          <button
            className="destroy"
            title="Delete todo"
            type="submit"
            name="intent"
            value="deleteTodo"
          />
        </deleteFetcher.Form>
      </div>
    </li>
  );
}
function ListItem({ todo, filter }: { todo: TodoItem; filter: Filter }) {
  const updateFetcher = useFetcher();
  const toggleFetcher = useFetcher();
  const deleteFetcher = useFetcher();
  const updateFormRef = React.useRef<HTMLFormElement>(null);

  const isToggling = Boolean(toggleFetcher.submission);

  const complete = todo.complete;

  const shouldRender =
    filter === "all" ||
    (filter === "complete" && complete) ||
    (filter === "active" && !complete);

  if (!shouldRender) return null;

  return (
    <li className={complete ? "completed" : ""}>
      <div className="view">
        <toggleFetcher.Form method="post">
          <input type="hidden" name="todoId" value={todo.id} />
          <input type="hidden" name="complete" value={(!complete).toString()} />
          <button
            type="submit"
            name="intent"
            value="toggleTodo"
            className="toggle"
            title={complete ? "Mark as incomplete" : "Mark as complete"}
            disabled={isToggling}
          >
            {complete ? <CompleteIcon /> : <IncompleteIcon />}
          </button>
        </toggleFetcher.Form>
        <updateFetcher.Form
          method="post"
          className="update-form"
          ref={updateFormRef}
        >
          <input type="hidden" name="intent" value="updateTodo" />
          <input type="hidden" name="todoId" value={todo.id} />
          <input
            name="title"
            className="edit-input"
            defaultValue={todo.title}
            onBlur={(e) => {
              if (todo.title !== e.currentTarget.value) {
                updateFetcher.submit(e.currentTarget.form);
              }
            }}
            aria-invalid={updateFetcher.data?.error ? true : undefined}
            aria-describedby={`todo-update-error-${todo.id}`}
            disabled={isToggling}
          />
          {updateFetcher.data?.error && updateFetcher.state !== "submitting" ? (
            <div
              className="error todo-update-error"
              id={`todo-update-error-${todo.id}`}
            >
              {updateFetcher.data?.error}
            </div>
          ) : null}
        </updateFetcher.Form>
        <deleteFetcher.Form method="post">
          <input type="hidden" name="todoId" value={todo.id} />
          <button
            className="destroy"
            title="Delete todo"
            type="submit"
            name="intent"
            value="deleteTodo"
            disabled={isToggling}
          />
        </deleteFetcher.Form>
      </div>
    </li>
  );
}

Progressive Enhancement 🤩

SPA capabilities + MPA mental model

Optimistic UI

function ListItem({ todo, filter }: { todo: TodoItem; filter: Filter }) {
  const updateFetcher = useFetcher();
  const toggleFetcher = useFetcher();
  const deleteFetcher = useFetcher();
  const updateFormRef = React.useRef<HTMLFormElement>(null);

  const isToggling = Boolean(toggleFetcher.submission);

  const complete = todo.complete;

  const shouldRender =
    filter === "all" ||
    (filter === "complete" && complete) ||
    (filter === "active" && !complete);

  if (!shouldRender) return null;

  return (
    <li className={complete ? "completed" : ""}>
      <div className="view">
        <toggleFetcher.Form method="post">
          <input type="hidden" name="todoId" value={todo.id} />
          <input type="hidden" name="complete" value={(!complete).toString()} />
          <button
            type="submit"
            name="intent"
            value="toggleTodo"
            className="toggle"
            title={complete ? "Mark as incomplete" : "Mark as complete"}
            disabled={isToggling}
          >
            {complete ? <CompleteIcon /> : <IncompleteIcon />}
          </button>
        </toggleFetcher.Form>
        <updateFetcher.Form
          method="post"
          className="update-form"
          ref={updateFormRef}
        >
          <input type="hidden" name="intent" value="updateTodo" />
          <input type="hidden" name="todoId" value={todo.id} />
          <input
            name="title"
            className="edit-input"
            defaultValue={todo.title}
            onBlur={(e) => {
              if (todo.title !== e.currentTarget.value) {
                updateFetcher.submit(e.currentTarget.form);
              }
            }}
            aria-invalid={updateFetcher.data?.error ? true : undefined}
            aria-describedby={`todo-update-error-${todo.id}`}
            disabled={isToggling}
          />
          {updateFetcher.data?.error && updateFetcher.state !== "submitting" ? (
            <div
              className="error todo-update-error"
              id={`todo-update-error-${todo.id}`}
            >
              {updateFetcher.data?.error}
            </div>
          ) : null}
        </updateFetcher.Form>
        <deleteFetcher.Form method="post">
          <input type="hidden" name="todoId" value={todo.id} />
          <button
            className="destroy"
            title="Delete todo"
            type="submit"
            name="intent"
            value="deleteTodo"
            disabled={isToggling}
          />
        </deleteFetcher.Form>
      </div>
    </li>
  );
}
function ListItem({ todo, filter }: { todo: TodoItem; filter: Filter }) {
  const updateFetcher = useFetcher();
  const toggleFetcher = useFetcher();
  const deleteFetcher = useFetcher();
  const updateFormRef = React.useRef<HTMLFormElement>(null);

  const isToggling = Boolean(toggleFetcher.submission);

  const complete = isToggling
    ? toggleFetcher.submission?.formData.get("complete") === "true"
    : todo.complete;

  const shouldRender =
    filter === "all" ||
    (filter === "complete" && complete) ||
    (filter === "active" && !complete);

  if (!shouldRender) return null;

  return (
    <li className={complete ? "completed" : ""}>
      <div className="view">
        <toggleFetcher.Form method="post">
          <input type="hidden" name="todoId" value={todo.id} />
          <input type="hidden" name="complete" value={(!complete).toString()} />
          <button
            type="submit"
            name="intent"
            value="toggleTodo"
            className="toggle"
            title={complete ? "Mark as incomplete" : "Mark as complete"}
          >
            {complete ? <CompleteIcon /> : <IncompleteIcon />}
          </button>
        </toggleFetcher.Form>
        <updateFetcher.Form
          method="post"
          className="update-form"
          ref={updateFormRef}
        >
          <input type="hidden" name="intent" value="updateTodo" />
          <input type="hidden" name="todoId" value={todo.id} />
          <input
            name="title"
            className="edit-input"
            defaultValue={todo.title}
            onBlur={(e) => {
              if (todo.title !== e.currentTarget.value) {
                updateFetcher.submit(e.currentTarget.form);
              }
            }}
            aria-invalid={updateFetcher.data?.error ? true : undefined}
            aria-describedby={`todo-update-error-${todo.id}`}
          />
          {updateFetcher.data?.error && updateFetcher.state !== "submitting" ? (
            <div
              className="error todo-update-error"
              id={`todo-update-error-${todo.id}`}
            >
              {updateFetcher.data?.error}
            </div>
          ) : null}
        </updateFetcher.Form>
        <deleteFetcher.Form method="post">
          <input type="hidden" name="todoId" value={todo.id} />
          <button
            className="destroy"
            title="Delete todo"
            type="submit"
            name="intent"
            value="deleteTodo"
          />
        </deleteFetcher.Form>
      </div>
    </li>
  );
}

Optimistic UI

switch (intent) {
  case "toggleTodo": {
    await prisma.todo.update({
      where: { id: todoId },
      data: { complete: formData.get("complete") === "true" },
    });
    return new Response("ok");
  }
  // ...
}
switch (intent) {
  case "toggleTodo": {
    return json({ error: "Random failure" }, { status: 500 });
    await prisma.todo.update({
      where: { id: todoId },
      data: { complete: formData.get("complete") === "true" },
    });
    return new Response("ok");
  }
  // ...
}

Automatic Rollback

Unlimited Optimism 😊

In Review

  • Progressive Enhancement is about accessibility
  • Remix enables Progressive Enhancement
  • Remix simplifies DX and improves UX (MPA + SPA)
  • Remix isn't MPA or SPA, it's PESPA (kcd.im/pespa)
  • Working without JavaScript is just as much about DX as it is about UX.

You are brilliant

Thank you!

Bringing Back Progressive Enhancement

By Kent C. Dodds

Bringing Back Progressive Enhancement

One fun (sometimes frustrating) part of the web is that your application must run on countless variations of devices with different configurations, screen sizes, and capabilities. Over the years, technology has improved and we've been given some really awesome APIs to enhance our applications to make them more useful despite the uniquely challenging (and awesome) distribution mechanism of the web. Unfortunately, when we use these new features of the web platform to enable our application, we limit our application's usefulness to the trade-offs of those features which has surprisingly negative impacts (even if the device itself supports those features). In this talk, we're going to learn the true scope of Progressive Enhancement and what can be gained by using tools and techniques that allow features of the web to enhance your user's experience rather than enable it.

  • 2,508