Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active May 2, 2021 16:01
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ryanflorence/07a5aad72fe6d4859a81cce0e3a37f94 to your computer and use it in GitHub Desktop.
Save ryanflorence/07a5aad72fe6d4859a81cce0e3a37f94 to your computer and use it in GitHub Desktop.

Calling Loaders/Actions Outside of Navigation

Some might call them "API routes".

Use cases:

  • Combobox suggestions in a form.
    • This is not semantically navigation
  • Single action buttons that don't quite feel like navigation
    • like clicking a bunch of delete buttons really fast in a list
  • Third party integration webhooks
    • They send GET and POST
    • Not navigation, not even part of a user flow, simply part of the app
  • User flows that necessarily involve browser fetches to third parties as well as app data mutations
    • Many third party services do not have server-side APIs
    • Building the user flow as it moves from third-party to remix endpoints is awkward
      • manage state in the browser
      • then manage state on the server
      • then drive next browser state from remix loaders
      • Examples:
        • Our own login/purchase/registration flows
        • uploading images to third-party as well as setting data in remix app data store

Why not just use an express/vercel/architect endpoint?

Indeed, this was our original thinking back when we had the data folder. We realized quickly that sharing code between compiled-by-remix and not-compiled-by-remix was overly complex, so we moved everything into the Remix app itself.

This is the same problem with "just using a server route instead of remix". Code re-use between the server-only routes (express/vercel/firebase, etc.) and the remix-compiled code is overly complex.

Additionally, you'll have to think in two paradigms: the web Request and Response model in Remix and the req/res of whatever platform you're using. Any abstractions you build on top of the web fetch API for your remix server-side code will be unusable outside of Remix.

API Proposal useFetchRoute()

You can call Remix route's with fetch directly if you know how to construct the URL and know the route id convention that Remix uses internally. You would also need to know the conventions Remix actions use to redirect (204 + header) since actions require a redirect.

However, even by doing all of that, you still can't point a webhook at a remix endpoint because most webhooks require a 2xx response.

All we really need is to support these use cases are two things:

  1. Provide a hook to call the data url of a route with fetch, basically a remix flavored useFetch
  2. Not enforce the redirect requirement for actions when called outside of navigations

This hook will call a route's data functions outside of navigation.

Combobox use case

// users/search.tsx
export function loader({ request }) {
  return searchUsers(request.url)
}

// Some route
function UsersCombobox(props) {
  // 1. returns a function identical to `fetch`
  let fetchRoute = useFetchRoute();
  let [users, setUsers] = useState([]);

  let fetchUsers = async (userInput) => {
    // 2. the difference is that it will
    //    - match this path against routes
    //    - change the url to the internal remix url for data requests
    let res = await fetchRoute(`/users/search?q=${userInput}`);
    let users = await res.json();
    setUsers(users);
  };

  return (
    <Combobox {...props} onChange={fetchUsers}>
      {users.map((user) => (
        <ComboboxItem value={user.id}>{user.email}</ComboboxItem>
      ))}
    </Combobox>
  );
}

function SomeRoute() {
  return (
    <Form>
      <label>
        Project Name: <input name="projectName" />
      </label>
      <label>
        Owner: <UsersCombobox name="owner" />
      </label>
    </Form>
  );
}

Mixed third-party browser + remix server side flow

For user flows that involved browser-only third-party tools and remix mutations, you can call actions directly:

export async function action({ request }) {
  let formParams = new URLSearchParams(await request.text());
  await createUserRecord(formParams);
  return json("", { status: 201 });
}

function Register() {
  let [state, setState] = useState("idle");
  let fetchRoute = useFetchRoute();

  useEffect(() => {
    switch (state) {
      // first step uses all browser side stuff
      case "authenticating": {
        await firebase.auth().signInWithPopup(githubProvider);
        setState("creatingUserRecord");
        break;
      }

      // second calls remix action to mutate server side data
      case "creatingUserRecord": {
        await fetchRoute("/users/create", {
          method: "post",
          body: JSON.stringify({ jwt: firebase.auth().getIdToken() }),
          headers: { "content-type": "application-json" },
        });
        setState("success");
        break;
      }
    }
  }, [state]);

  return (
    <button onClick={() => setState("authenticating")}>
      Sign up with GitHub
    </button>
  );
}

If you model this as a navigation, it's way more involved and a little indirect

import { getSession, commitSession } from "./session";

export async function action({ request }) {
  let formParams = new URLSearchParams(await request.text());
  await createUserRecord(formParams);

  // have to get sessions involved to pass data from action -> loader -> component
  let cookieSession = await getSession(request.headers.get("Cookie"));
  cookieSession.flash("nextClientState", "success");
  return redirect("/register");
}

// Didn't even need a loader before, now we've got a bunch of sessions management
export async function loader({ request }) {
  let session = await getSession(request.headers.get("Cookie"));
  if (session.has("nextClientState")) {
    return json(
      { nextClientState: session.get("nextState") },
      {
        headers: { "Set-Cookie": await commitSession(session) },
      }
    );
  }
  return null;
}

function Register() {
  let [state, setState] = useState("idle");
  let data = useRouteData();
  let submit = useFormSubmit();

  // have to use a weird effect to move along the client side state, you can't
  // just use `usePendingFormSubmit` for pending UI because the flow has a
  // dependency on async calls outside of (and before) the form submit
  useEffect(() => {
    if (data && data.nextClientState === "success") {
      setState("success");
    }
  }, [data]);

  useEffect(() => {
    switch (state) {
      // first step uses all browser side stuff
      case "authenticating": {
        await firebase.auth().signInWithPopup(githubProvider);
        setState("creatingUserRecord");
        break;
      }

      // second calls remix action to mutate server side data
      case "creatingUserRecord": {
        submit({ jwt: firebase.auth().getIdToken() }, { action: "/register" });
        break;
      }
    }
  }, [state]);

  return (
    <button onClick={() => setState("authenticating")}>
      Sign up with GitHub
    </button>
  );
}

Webhook Example

import { status } from "@remix-run/data";

export function action({ request }) {
  // can use application/json requests since not coming from a form
  let { action, data } = await request.json();
  switch (action) {
    case "payment_process": {
      // can easily share code between normal navigation actions and webhook
      // code since it's all compiled by remix.
      Database.update(`users/${data.user.id}`, {
        paymentId: data.payment.id,
      });
      // can even use remix-provided http helpers, which wouldn't be possible in
      // a server route
      return status(202);
    }
    // etc...
  }
}

Deleting a bunch of records in a list

If you've got a list of items and click delete on multiple quickly, the pending or optimistic UI in remix is impossible to build since only one form can be pending at a time. The last clicked item is the one that will show is deleting, while others you may have already clicked will stop rendering their pending UI.

We have discussed allowing multiple pending forms at once, but it's still unclear how that API should look (usePendingFormSubmits() with an "s" would probably be okay). So we may be able to figure something out here to model as navigation (which would be good so that it works if JS fails, or you want to be able to turn it off and on ...)

So, until we tackle that, this UI isn't straightforward to build Remix right now. But with useFetchRoute it's pretty straightforward React.

// /project/$projectId/todos/$todoId.js
export function action({ params }) {
  await Database.delete("todos", params.todoId);
  return status(204);
}

// /project/$projectId
export function loader({ params }) {
  return Database.find(`projects/${params.projectId}`);
}

export function TodoItem({ todo, onDelete }) {
  let fetchRoute = useFetchRoute();
  let [deleting, setDeleting] = useState(false);

  let deleteTodo = async () => {
    setDeleting(true);
    await fetchRoute(`todos/${todo.id}`, { method: "post" });
    onDelete();
  };

  return (
    <div>
      {todo.name}
      <button onClick={() => deleteTodo()}>Delete</button>
    </div>
  );
}

export default function Project() {
  let project = useRouteData();
  let [todos, setTodos] = useState(project.todos);
  let removeTodo = (todo) =>
    setTodos(todos.filter((alleged) => todo !== alleged));

  return (
    <div>
      <h1>{project.name}</h1>
      <ul>
        {project.todos.map((todo) => {
          return (
            <li>
              <TodoItem todo={todo} onDelete={() => removeTodo(todo)} />
            </li>
          );
        })}
      </ul>
    </div>
  );
}

Of course, this is fraught with data synchrony issues, which is why this really is a navigation, because remix will automatically recall loaders up the tree to make sure the mutation is reflected.

But until we figure out "multiple pending forms" this would allow people to continue building UI the way they're used to in React but now with Remix handling the backend "api route".

In Summary

  • This API will be great for data use cases that are not navigation
  • This API will be (ab)used for use cases that really should be navigation but at least it's an escape hatch for when either Remix is deficient (no multiple pending forms) or a developer hasn't quite caught the vision of "navigation for mutation".

Implementation notes

Special case calls from navigation to actions with a header so that we continue to enforce a redirect. If the header isn't present, then we don't care what they return, it's not a navigation.

Alternative API

Instead of a full fetch-like object, just return the data url, and then use that anywhere you want (including fetch).

let dataPath = useDataPath("/some/route")
// normal window.fetch
fetch(dataPath)

This is probably better because then people can use React Data, useSWR, and any other client-side data library out there.

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