Defining loaders and actions anywhere in the module graph #5383
Replies: 21 comments 58 replies
-
I see this would be useful for dashboard-type routes. I have a POC that shows component loaders, but this was done manually by importing the loaders into the main route. https://github.com/kiliman/remix-component-data I have a couple of questions: Will this support streaming/defer? I plan to update my POC with Also, one of the issues with using route loaders for component data is that revalidation will trigger the loaders of all the components. In most instances, each widget should be able to control its data, and the initial route loader was just to "prime the pump." Currently, Anyway, great proposal. |
Beta Was this translation helpful? Give feedback.
-
nvm, I am writing a sibling routes proposal instead |
Beta Was this translation helpful? Give feedback.
-
This reminds me a lot to Blitz, and it gives me mixed feelings. On one side there's a clear benefit where you can build truly full-stack components with all the code related to the component in the same file without having to keep manually combining their loaders or actions. On the other side, this will hide a lot of things, is Remix going to create a unique route per component? Where is that placed if I wanted to fetch it myself? Or is it going to merge them in the route where the component is used? If it's merged, how will that happens? What would be the response shape? What happens if the same component with a loader/action is used in more than one route? I know that those are more implementation details and the dev using this feature will not need to care about that at all supposedly, until you need to debug something. This hiding things feels against what Remix has done so far. |
Beta Was this translation helpful? Give feedback.
-
Would be super stoked to see this in Remix. For every app dev that "thinks in components", being able to organize data needs and actions alongside small bits of UI without the orchestration of route-to-component prop passing, context, and/or giving up PE and using |
Beta Was this translation helpful? Give feedback.
-
A convention that SolidStart and Qwik have used to denote "compiler magic happening" is adding a |
Beta Was this translation helpful? Give feedback.
-
I don't believe this is the way forward. If Remix is going to add compiler transforms, it should be for React Server Components - these actually solve all of the stated problems here without any levels of indirection. export function Project({ params }) {
let project = await db.getProject(params.projectId)
return <div>{project.name}</div>;
} |
Beta Was this translation helpful? Give feedback.
-
i'm somewhat in favor of this proposal as it would remove pretty much all of the appeal that devs would have for data fetching in components while retaining the performance characteristics of remix. doing a bit of early bikeshedding here, i think the loader api could make more sense if it was more explicit and looked something like: import { createLoader, useLoader } from "@remix-run/react";
let projectLoader = createLoader(({ params }) => {
return db.getProject(params.projectId);
});
export function Project() {
let project = useLoader(projectLoader);
return <div>{project.name}</div>;
} as that would avoid the need to do this |
Beta Was this translation helpful? Give feedback.
-
I see this as a huge win for those trying to scale Remix to large sites. Take any Amazon product page as an example. There's 20+ independent and large components on a single route: reviews, buying options, Q&A, product details, videos, comparisons, recommendations, ... . Coupling all those together in a single loader, even if just doing orchestration, would be a maintainability issue. Those who have simple sites and requirements can keep using the one route loader I'd assume, but for those with large and complex sites with functionality and widgets developed across many teams, this would help a lot.
Now to really scale to the next level, there needs to be components that can be deployed independently so that the customer review team doesn't have to talk to the personal recommendations team in the other timezone/biz domain. This doesn't solve that but I think is a step in the right direction. LocalizationAlso just an idea to consider and spark discussion, I thought this could be used to simplify localization without polluting every single route loader with prompts in the response or configuring clientLoader and handle for each route. Instead by simply importing the useTranslations hook it will automatically add the query seamlessly with no extra config for feature devs to worry about. If these files could be distributed as npm packages and still have the compiler hoist createLoader, then this could simplify localization setup. One challenge is getting the required namespace from the component in the generic loader, open to ideas!. E.g. // useTranslation.ts
import { useTranslation as useTranslationOrig } from 'react-i18next';
let getTranslations = createLoader(({ params }) => {
const namespace = getNamespace(); // namespaces would likely have to be a convention based on current url?
return loadTranslations(`./locales/${params.lng}/${namespace}.json`);
});
export function useTranslation(ns, options) {
const lng = useLocale();
if(!i18next.hasResourceBundle(lng, ns)) {
let translations = getTranslations();
i18next.addResourceBundle(lng, ns, translations);
}
if (i18next.resolvedLanguage !== lng) i18next.changeLanguage(lng)
return useTranslationOrig(ns, options);
}
// app/routes/products.tsx
export default function Products() {
let { t } = useTranslation("products");
return <div>{t("my product")}</div>;
} |
Beta Was this translation helpful? Give feedback.
-
Another great benefit of this API would be to have multiple loaders in one route import { createLoader, useLoader } from "@remix-run/react"
let cachableDataLoader = createLoader(({ params }) => {
const cacheableData = await db.get(params.cacheId);
return json(cacheableData, { headers: { 'Cache-Control': 'max-age=300' } })
})
let projectLoader = createLoader(({ params }) => {
return json(await db.getProject(params.projectId))
})
export function Project() {
let other = useLoader(cachableDataLoader)
let project = useLoader(projectLoader)
return ...
} |
Beta Was this translation helpful? Give feedback.
-
If remix replaces Something like this might look valid but actually isn't.
If the API was |
Beta Was this translation helpful? Give feedback.
-
This is apparently an unpopular opinion but... I don't like this at all. It feels like it's going to lead to much messier and harder to maintain systems. Why? Because there ain't no such thing as a free lunch. This feels almost as wrong as monkeypatching fetch. It feels good right now, because there's some tedium and pain around declaring and defining component data dependencies. But in my view, it's precisely those constraints that are core to why Remix is so good. Requiring your data fetching to be done at the route loader level directs you to think about your page structure in a certain way, which leads to (in. my opinion) a better, more maintainable structure / architecture. Constraints, and limits, are a Good Thing™. Rubbing up against those constraints and feeling that friction is generally (but not always) a signal that you might be doing something you shouldn't. I know we're all engineers, and our natural tendency is to immediately try to solve pain points, but... we've been down this path. We've gone down the whole "let the compiler do some magical thing to make our lives easier now", and then when "later" shows up, you gotta pay the piper. And it's not fun. Remix's strength comes from its incredibly small surface area and reliance on embracing the web, and embracing standards. Crossing this line can enable powerful patterns and features, but to use an already overused cliché: there's a responsibility there as well. It will effectively open Pandora's Box, and that's hard to close again. I don't think y'all should take compiler magic off the table. But I think the cost of this kind of thing, in downstream effects, is incredibly high, and the reasons stated here for introducing it don't come close to justifying the possible repercussions here. (I'm sorry if I'm coming off as a bit alarmist, it's a topic that I feel pretty strongly about). |
Beta Was this translation helpful? Give feedback.
-
My problem with this suggestion is mostly that this separation of loaders looks more useful than it will be in practice. Sometimes the data you load are dependent of other data: const getCart = createLoader(({ request }) => fetchCart(request));
const getRecommendedProducts = createLoader(({ request }) => {
const cart = await fetchCart(request);
return fetchRecommendedProducts(cart.lines);
}); Now, you have to make sure you deduplicate the This kind of optimization "left to the user" kind of reminds me how the user can make sure to not have waterfalls in RSC. |
Beta Was this translation helpful? Give feedback.
-
Instead of code transforming, how about putting // /app/components/List/index.action.tsx <= the action suffix indicates this is a action file of the component
import { createAction, createLoader } from "@remix-run/react";
export let updateProject = createAction(async ({ request, params }) => {
let updates = await request.formData();
return db.updateProject(params.id, updates);
});
export let deleteProject = createAction(async ({ request }) => {
let formData = await request.formData();
return db.deleteProject(formData.get("id"));
});
export let getProject = createLoader(({ params }) => {
return db.getProject(params.projectId);
}); // /app/components/List/index.tsx
import { getProject, updateProject, deleteProject } from './index.action';
export function Project() {
let project = useLoaderData<typeof getProject>();
return (
<>
<h1>{project.title}</h1>
<Form action={updateProject.endpoint}> <= action exposes a endpoint property points to the action route
<input type="text" name="title" />
<button type="submit">Save</button>
</Form>
<Form action={deleteProject.endpoint}>
<button type="submit" name="id" value={project.id}>
Delete
</button>
</Form>
</>
);
} |
Beta Was this translation helpful? Give feedback.
-
I love this proposal as it's just the compiler moving some code around, not changing any Remix fundamentals, and most likely reducing confusion around what is and isn't allowed to have loaders/actions. I did notice something missing, though: component-level ErrorBoundary (and CatchBoundary). It seems like it would add confusion to only be able to have route-level error boundaries, but define loaders and actions that could error anywhere. The loader for a |
Beta Was this translation helpful? Give feedback.
-
This proposal combines loader definition via I want to call out that these two concerns are orthogonal. For example, we could have a import { json, createLoader$} from "@remix-run/..."
// can define `peopleLoader` here or import from another file, doesn't matter
let peopleLoader = createLoader$(() => {
let raw = fs.readFileSync("people.json", "utf8")
let people = JSON.parse(raw)
return json({ people })
})
// ^ typechecks like `satisfies LoaderFunction` but without having to write that manually!
// explicitly declare what loaders this route uses (parent loaders still get run automatically, this is just for new loaders for this route)
export let loaders = [peopleLoader]
export default Route() {
let { people } = peopleLoader.useData() // look ma, no `typeof loader`!
return <pre>{JSON.stringify(people)}</pre>
} Using something like a // for browser build, compiler replaces `createLoader$` with something like:
let peopleLoader = { useData: () => useLoaderData(peopleLoaderId) } So if you don't like the compiler auto-detecting the loader usages in your code (which I agree is a bit more magic), you might still like the explicitness and DX of createLoader with no auto-detection but instead explicit loaders export. Footnotes
|
Beta Was this translation helpful? Give feedback.
-
@ryanflorence I keep thinking: Could this sort of thing be designed as a form of progressive enhancement? The uncompiled vanilla JavaScript version might involve double-/pre-rendering to figure out which loaders are being defined for a given route (at least on first render), somthing, something... |
Beta Was this translation helpful? Give feedback.
-
Is there still interest in this? What's the team's current thoughts on this? |
Beta Was this translation helpful? Give feedback.
-
@ryanflorence really looking forward to seeing this, any update? Or any workarounds you'd recommend in the mean time? |
Beta Was this translation helpful? Give feedback.
-
Hello everyone, I've been mulling over the topic as well as the inputs shared by @rossipedia . I reckon another approach could be to maintain the route loader while also feeding it all defined loaders from its module graph. This would be encapsulated in an object, offering dynamic call functionality along with the ability to include custom parameters, contexts, and more. Here's the API I've been considering: // blogPostList.tsx
// blogLoader is a Symbol
export const blogLoader = createLoader(function loader({params}){
const posts = loadBlogs(params.page)
return json(posts)
})
export function BlogList() {
const posts = useLoader(blogLoader)
...
}
// $pageSlug.tsx
import { blogLoader } from '~/blogPostList'
export function loader({ params, childLoaders }) {
const pageData = await loadPage(params.pageSlug)
for ([loaderKey, childLoader] of Object.entries(childLoaders)) {
if (loaderKey === blogLoader && pageData.some(data => data.type === 'blog-list')) {
childLoader({ params: { page: 0 }})
} else if (loaderKey !== blogLoader) {
childLoader()
}
return json(pageData)
} In this scenario, 'childLoaders' acts as a dictionary where the loader key maps to a function that triggers the corresponding loader. The parameters of these loaders can be overridden, and the loaders themselves can be called concurrently. If you'd like to call all child loaders without overriding any parameters, it's as simple as iterating over them like so: function loader({childLoaders}) {
Object.values(childLoaders).map(childLoader => childLoader())
} Or childLoaders could already be a list of entries. What I am in general not sure about is: what if two components are rendered twice and would need different data. Would love to hear your thoughts on this approach. |
Beta Was this translation helpful? Give feedback.
-
Any updates on this? |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
tl;dr
Background
Remix is kind of butthole about where you can define your data needs: route loaders. We do this so that the "right thing is the easy thing". If you load data inside of components you slow down your app so we made that hard.
However, this "right thing" isn't always easy, or at least not as easy as it could be.
With some compiler tricks, we could allow app developers to define data needs anywhere in their app, not just route loaders and we could do it without sacrificing the parallel loading design of Remix.
createLoader
getProject
so we get great types w/o app-level genericsHow it could work:
createLoader
in each route's module graphuseLoader(someId)
createLoader
calls.Limited to the module scope:
Any calls to
createLoader
must be in the module scope so that people don't try to pass component state/props to the enclosing function.This would throw an error at build time that they aren't allowed to create loaders outside of the module scope.
I suppose if we wanted to get really fancy we could allow this but we would need to throw an error if they use variables in the component scope (or any route export that makes it to the client).
createAction
We could do the same for actions:
Similarly, this will hoist the actions to the internal route action and replace the call sites with URLs, maybe something like
<form action="/this/route?__action={someId}">
.To access
actionData
it could work similarly to loaderData.That looks weird though, so maybe do something like:
Bundler Shenanigans
At the moment our bundler makes superficial changes to app code:
<div/>
tojsx("div")
None of these transforms really change the semantics of the code.
This proposal will cross a line we haven't crossed yet. Is it worth crossing for the DX of these APIs?
Beta Was this translation helpful? Give feedback.
All reactions