Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calling Loaders/Actions outside of navigation (or other apps) #169

Closed
ryanflorence opened this issue May 15, 2021 · 7 comments
Closed

Calling Loaders/Actions outside of navigation (or other apps) #169

ryanflorence opened this issue May 15, 2021 · 7 comments

Comments

@ryanflorence
Copy link
Member

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
  • Other apps consuming the remix app as an API
  • 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.

@sergiodxa
Copy link
Member

About the alternative API, you could still use useFetch with React Query or SWR, so the first option could work, something like:

let fetch = useFetchRoute
useQuery("autocomplete", () => fetch(“some url”))

@sergiodxa
Copy link
Member

Also, shouldn’t there be a way in the parameters of the action a way to identify if your action is being used in a navigation or not so you can return a redirect or json?

@ryanflorence
Copy link
Member Author

@sergiodxa that's a good point. I think I assumed you'd have actions that are always used as navigations, and actions that aren't, didn't really expect to have an action that is used either way. We'll need to think about that.

Anyway, knee-jerk reaction is we send a header: request.headers.get("X-Remix-Navigation")

@sergiodxa
Copy link
Member

A header will work pretty well, and I assume it will be easier to add and if it’s a standard header of Remix you can build a helper function that receives the request and return a Boolean

let isNavigation = checkNavigationHeader(request)

(Probably isNavigation should be the function name, since it’s a sync thing you could just call it anytime you need it)

@amuttsch
Copy link

amuttsch commented May 17, 2021

In my current project we use next.js API routes to proxy all our backend calls. Our API is accessed at https://our-website.com/api/... which is split using CloudFront behaviours. In order to be able develop locally we have a catch all route in the pages/api folder that uses http-proxy-middleware to forward all API requests to the backend.

Would this be possible with this proposed solution?

@sergiodxa
Copy link
Member

@amuttsch I think you don’t need that with Remix, you will call your API inside the loaders and actions so you don’t need to expose your API

ryanflorence added a commit that referenced this issue Aug 24, 2021
This is kinda big but opens up a lot of use cases,

## useTransition:

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations

## useActionData

- actions can return data now
- super useful for form validation, no more screwing around with sessions

## useFetcher

- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

## experimental_shouldReload

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

## other stuff

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

## Deprecations

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Closes #169, #151, #175, #128, #54, #208
ryanflorence added a commit that referenced this issue Aug 24, 2021
This is kinda big but opens up a lot of use cases,

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations

- actions can return data now
- super useful for form validation, no more screwing around with sessions

- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Closes #169, #151, #175, #128, #54, #208
ryanflorence added a commit that referenced this issue Aug 25, 2021
This is kinda big but opens up a lot of use cases,

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations

- actions can return data now
- super useful for form validation, no more screwing around with sessions

- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Closes #169, #151, #175, #128, #54, #208
ryanflorence added a commit that referenced this issue Aug 25, 2021
This is kinda big but opens up a lot of use cases,

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations

- actions can return data now
- super useful for form validation, no more screwing around with sessions

- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Closes #169, #151, #175, #128, #54, #208
ryanflorence added a commit that referenced this issue Aug 25, 2021
This is kinda big but opens up a lot of use cases,

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations
- actions can return data now
- super useful for form validation, no more screwing around with sessions
- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

other stuff

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Also includes a helping of docs updates

Closes #169, #151, #175, #128, #54, #208
ryanflorence added a commit that referenced this issue Aug 25, 2021
This is kinda big but opens up a lot of use cases,

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations
- actions can return data now
- super useful for form validation, no more screwing around with sessions
- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

other stuff

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Also includes a helping of docs updates

Closes #169, #151, #175, #128, #54, #208
ryanflorence added a commit that referenced this issue Aug 25, 2021
This is kinda big but opens up a lot of use cases,

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations
- actions can return data now
- super useful for form validation, no more screwing around with sessions
- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

other stuff

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Also includes a helping of docs updates

Closes #169, #151, #175, #128, #54, #208
ryanflorence added a commit that referenced this issue Aug 26, 2021
This is kinda big but opens up a lot of use cases,

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations
- actions can return data now
- super useful for form validation, no more screwing around with sessions
- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

other stuff

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Also includes a helping of docs updates

Closes #169, #151, #175, #128, #54, #208
ryanflorence added a commit that referenced this issue Sep 1, 2021
This is kinda big but opens up a lot of use cases,

- can build better pending navigation UI with useTransition telling app more detailed information (state, type)
- replaces usePendingFormSubmit and usePendingLocation
- actually aborts stale submissions/loads
- fixes bugs around interrupted submissions/navigations
- actions can return data now
- super useful for form validation, no more screwing around with sessions
- allows apps to call loaders and actions outside of navigation
- manages cancellation of stale submissions and loads
- reloads route data after actions
- commits the freshest reloaded data along the way when there are multiple inflight

allows route modules to decide if they should reload or not

- after submissions
- when the search params change
- when the same href is navigated to

other stuff

- reloads route data when the same href is navigated to
- does not create ghost history entries on interrupted navigation

These old hooks still work, but have been deprecated for the new hooks.

- useRouteData -> useLoaderData
- usePendingFormSubmit -> useTransition().submission
- usePendingLocation -> useTransition().location

Also includes a helping of docs updates

Closes #169, #151, #175, #128, #54, #208
@ryanflorence
Copy link
Member Author

coming in 0.19 as useFetcher

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants