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!