|
| 1 | +# Middleware + Context |
| 2 | + |
| 3 | +Date: 2025-01-22 |
| 4 | + |
| 5 | +Status: accepted |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +_Lol "context", get it 😉_ |
| 10 | + |
| 11 | +The [Middleware RFC][rfc] is the _most-upvoted_ RFC/Proposal in the React Router repo. We actually tried to build and ship it quite some time ago but realized that without single fetch it didn't make much sense in an SSR world for 2 reasons: |
| 12 | + |
| 13 | +- With the individual HTTP requests per loader, middleware wouldn't actually reduce the # of queries to your DB/API's - it would just be a code convenience with no functional impact |
| 14 | +- Individual HTTP requests meant a lack of a shared request scope across routes |
| 15 | + |
| 16 | +We've done a lot of work since then to get us to a place where we could ship a middleware API we were happy with: |
| 17 | + |
| 18 | +- Shipped [Single Fetch][single-fetch] |
| 19 | +- Shipped [`dataStrategy`][data-strategy] for DIY middleware in React Router SPAs |
| 20 | +- Iterated on middleware/context APIs in the [Remix the Web][remix-the-web] project |
| 21 | +- Developed a non-invasive type-safe + composable [context][async-provider] API |
| 22 | + |
| 23 | +## Decision |
| 24 | + |
| 25 | +### Leverage a new type-safe `context` API |
| 26 | + |
| 27 | +We originally considered leaning on our existing `context` (`type AppLoadContext`) value we pass to server-side `loader` and `action` functions as the `context` for middleware functions. Using this would make for an easier adoption of middleware for apps that use `AppLoadContext` today. However, there were a few downsides to that approach. |
| 28 | + |
| 29 | +First, the type story is lacking because it's just a global interface you augment via declaration merging so it's not true type safety and is more of a "trust me on this" scenario. We've always known it wasn't a great typed API and have always assumed we'd enhance it at some point via a breaking change behind a future flag. The introduction of middleware should result in much _more_ usage of `context` than exists today since it'll open up to user of `react-router-serve` as well. For this reason it made more sense to ship the breaking change flag now for the smaller surface area of `context`-enabled apps users, instead of later for a much larger surface area of apps. |
| 30 | + |
| 31 | +Second, in order to implement client-side middleware, we need to introduce a new `context` concept on the client - and we would like that to be the same API as we have on the server. So, if we chose to stick with `AppLoadContext`, we'd then have to implement a brand new `ClientAppLoadContext` which would suffer the same type issues out of the gate. It felt lazy to ship a known-subpar-API to the client. Furthermore, even if we did ship it - we'd _still_ want to enhance it later - so we'd be shipping a mediocre client `context` API _knowing_ that we would be breaking shortly after with a better typed API. |
| 32 | + |
| 33 | +That is why we decided to rip the band-aid off and include the breaking `context` change with the initial release of middleware. When the flag is enabled, we'll be replacing `AppLoadContext` with a new type-safe `context` API that is similar in usage to the `React.createContext` API: |
| 34 | + |
| 35 | +```ts |
| 36 | +let userContext = unstable_createContext<User>(); |
| 37 | + |
| 38 | +const userMiddleware: Route.unstable_MiddlewareFunction = async ({ |
| 39 | + context, |
| 40 | + request, |
| 41 | +}) => { |
| 42 | + context.set(userContext, await getUser(request)); |
| 43 | +}; |
| 44 | + |
| 45 | +export const middleware = [userMiddleware]; |
| 46 | + |
| 47 | +// In some other route |
| 48 | +export async function loader({ context }: Route.LoaderArgs) { |
| 49 | + let user = context.get(userContext); |
| 50 | + let posts = await getPosts(user); |
| 51 | + return { posts }; |
| 52 | +} |
| 53 | +``` |
| 54 | + |
| 55 | +If you have an app already using `AppLoadContext`, you don't need to split that out, and can instead stick that object into it's own context value and maintain the same shape: |
| 56 | + |
| 57 | +```diff |
| 58 | ++ let appContext = unstable_createContext<AppLoadContext>() |
| 59 | + |
| 60 | +function getLoadContext(req, res) { |
| 61 | + let appLoadContext = { /* your existing object */ }; |
| 62 | + |
| 63 | +- return appLoadContext |
| 64 | ++ return new Map([[appContext, appLoadContext]]); |
| 65 | +} |
| 66 | + |
| 67 | +function loader({ context }) { |
| 68 | +- context.foo.something(); |
| 69 | ++ // Hopefully this can be done via find/replace or a codemod |
| 70 | ++ context.get(appContext).foo.something() |
| 71 | + // ... |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +#### Client Side Context |
| 76 | + |
| 77 | +In order to support the same API on the client, we will also add support for a client-side `context` of the same type (which is already a [long requested feature][client-context]). If you need to provide initial values (similar to `getLoadContext` on the server), you can do so with a new `getContext` method which returns a `Map<RouterContext, unknown>`: |
| 78 | + |
| 79 | +```ts |
| 80 | +let loggerContext = unstable_createContext<(...args: unknown[]) => void>(); |
| 81 | + |
| 82 | +function getContext() { |
| 83 | + return new Map([[loggerContext, (...args) => console.log(...args)]]) |
| 84 | +} |
| 85 | + |
| 86 | +// library mode |
| 87 | +let router = createBrowserRouter(routes, { unstable_getContext: getContext }) |
| 88 | + |
| 89 | +// framework mode |
| 90 | +return <HydratedRouter unstable_getContext={getContext}> |
| 91 | +``` |
| 92 | + |
| 93 | +`context` on the server has the advantage of auto-cleanup since it's scoped to a request and thus automatically cleaned up after the request completes. In order to mimic this behavior on the client, we'll create a new object per navigation/fetch. |
| 94 | + |
| 95 | +### API |
| 96 | + |
| 97 | +We wanted our middleware API to meet a handful of criteria: |
| 98 | + |
| 99 | +- Allow users to perform logic sequentially top-down before handlers are called |
| 100 | +- Allow users to modify the outgoing response bottom-up after handlers are called |
| 101 | +- Allow multiple middlewares per route |
| 102 | + |
| 103 | +The middleware API we landed on to ship looks as follows: |
| 104 | + |
| 105 | +```ts |
| 106 | +const myMiddleware: Route.unstable_MiddlewareFunction = async ( |
| 107 | + { request, context }, |
| 108 | + next |
| 109 | +) => { |
| 110 | + // Do stuff before the handlers are called |
| 111 | + context.user = await getUser(request); |
| 112 | + // Call handlers and generate the Response |
| 113 | + let res = await next(); |
| 114 | + // Amend the response if needed |
| 115 | + res.headers.set("X-Whatever", "stuff"); |
| 116 | + // Propagate the response up the middleware chain |
| 117 | + return res; |
| 118 | +}; |
| 119 | + |
| 120 | +// Export an array of middlewares per-route which will run left-to-right on |
| 121 | +// the server |
| 122 | +export const middleware = [myMiddleware]; |
| 123 | + |
| 124 | +// You can also export an array of client middlewares that run before/after |
| 125 | +// `clientLoader`/`clientAction` |
| 126 | +const myClientMiddleware: Route.unstable_ClientMiddlewareFunction = ( |
| 127 | + { context }, |
| 128 | + next |
| 129 | +) => { |
| 130 | + //... |
| 131 | +}; |
| 132 | + |
| 133 | +export const clientMiddleware = [myClientSideMiddleware]; |
| 134 | +``` |
| 135 | + |
| 136 | +If you only want to perform logic _before_ the request, you can skip calling the `next` function and it'll be called and the response propagated upwards for you automatically: |
| 137 | + |
| 138 | +```ts |
| 139 | +const myMiddleware: Route.unstable_MiddlewareFunction = async ({ |
| 140 | + request, |
| 141 | + context, |
| 142 | +}) => { |
| 143 | + context.user = await getUser(request); |
| 144 | + // Look ma, no next! |
| 145 | +}; |
| 146 | +``` |
| 147 | + |
| 148 | +The only nuance between server and client middleware is that on the server, we want to propagate a `Response` back up the middleware chain, so `next` must call the handlers _and_ generate the final response. In document requests, this will be the rendered HTML document, and in data requests this will be the `turbo-stream` `Response`. |
| 149 | + |
| 150 | +Client-side navigations don't really have this type of singular `Response` - they're just updating a stateful router and triggering a React re-render. Therefore, there is no response to bubble back up and the next function will run handlers but won't return anything so there's nothing to propagate back up the middleware chain. |
| 151 | + |
| 152 | +### Client-side Implementation |
| 153 | + |
| 154 | +For client side middleware, up until now we've been recommending that if folks want middleware they can add it themselves using `dataStrategy`. Therefore, we can leverage that API and add our middleware implementation inside our default `dataStrategy`. This has the primary advantage of being very simple to implement, but it also means that if folks decide to take control of their own `dataStrategy`, then they take control of the _entire_ data flow. It would have been confusing if a user provided a custom `dataStrategy` in which they wanted to do their own middleware approach - and the router was still running it's own middleware logic before handing off to `dataStrategy`. |
| 155 | + |
| 156 | +If users _want_ to take control over `loader`/`action` execution but still want to use our middleware flows, we should provide an API for them to do so. The current thought here is to pass them a utility into `dataStrategy` they can leverage: |
| 157 | + |
| 158 | +```ts |
| 159 | +async function dataStrategy({ request, matches, defaultMiddleware }) { |
| 160 | + let results = await defaultMiddleware(() => { |
| 161 | + // custom loader/action execution logic here |
| 162 | + }); |
| 163 | + return results; |
| 164 | +} |
| 165 | +``` |
| 166 | + |
| 167 | +One consequence of implementing middleware as part of `dataStrategy` is that on client-side submission requests it will run once for the action and again for the loaders. We went back and forth on this a bit and decided this was the right approach because it mimics the current behavior of SPA navigations in a full-stack React Router app since actions and revalidations are separate HTTP requests and thus run the middleware chains independently. We don't expect this to be an issue except in expensive middlewares - and in those cases the context will be shared between the action/loader chains and the second execution can be skipped if necessary: |
| 168 | + |
| 169 | +```ts |
| 170 | +const expensiveMiddleware: Route.unstable_ClientMiddleware = async function ({ |
| 171 | + request, |
| 172 | + context, |
| 173 | +}) { |
| 174 | + // Guard this such that we use the existing value if it exists from the action pass |
| 175 | + context.something = context.something ?? (await getExpensiveValue()); |
| 176 | +}; |
| 177 | +``` |
| 178 | + |
| 179 | +**Note:** This will make more sense after reading the next section, but it's worth noting that client middlewares _have_ to be run as part of `dataStrategy` to avoid running middlewares for loaders which have opted out of revalidation. The `shouldRevalidate` function decodes which loaders to run and does so using the `actionResult` as an input. so it's impossible to decide which loaders will be _prior_ to running the action. So we need to run middleware once for the action and again for the chosen loaders. |
| 180 | + |
| 181 | +### Server-Side Implementation |
| 182 | + |
| 183 | +Server-side middleware is a bit trickier because it needs to propagate a Response back upwards. This means that it _can't_ be done via `dataStrategy` because on document POST requests we need to know the results of _both_ the action and the loaders so we can render the HTML response. And we need to render the HTML response a single time in `next`, which means middleware can only be run once _per request_ - not once for actions and once for loaders. |
| 184 | + |
| 185 | +This is an important concept to grasp because it points out a nuance between document and data requests. GET navigations will behave the same because there is a single request/response for both document and data GET navigations. POST navigations are different though: |
| 186 | + |
| 187 | +- A document POST navigation (JS unavailable) is a single request/response to call action+loaders and generate a single HTML response. |
| 188 | +- A data POST navigation (JS available) is 2 separate request/response's - one to call the action and a second revalidation call for the loaders. |
| 189 | + |
| 190 | +This means that there may be a slight difference in behavior of your middleware when it comes to loaders if you begin doing request-specific logic: |
| 191 | + |
| 192 | +```ts |
| 193 | +function weirdMiddleware({ request }) { |
| 194 | + if (request.method === "POST") { |
| 195 | + // ✅ Runs before the action/loaders on document submissions |
| 196 | + // ✅ Runs before the action on data submissions |
| 197 | + // ❌ Does not runs before the loaders on data submission revalidations |
| 198 | + } |
| 199 | +} |
| 200 | +``` |
| 201 | + |
| 202 | +Our suggestion is mostly to avoid doing request-specific logic in middlewares, and if you need to do so, be aware of the behavior differences between document and data requests. |
| 203 | + |
| 204 | +### Scenarios |
| 205 | + |
| 206 | +The below outlines a few sample scenarios to give you an idea of the flow through middleware chains. |
| 207 | + |
| 208 | +The simplest scenario is a document `GET /a/b` request: |
| 209 | + |
| 210 | +- Start a `middleware` |
| 211 | +- Start b `middleware` |
| 212 | +- Run a/b `loaders` in parallel |
| 213 | +- Render HTML `Response` to bubble back up via `next()` |
| 214 | +- Finish b `middleware` |
| 215 | +- Finish a `middleware` |
| 216 | + |
| 217 | +If we introduce `clientMiddleware` but no `clientLoader` and client-side navigate to `/a/b`: |
| 218 | + |
| 219 | +- Start a `clientMiddleware` |
| 220 | +- Start b `clientMiddleware` |
| 221 | +- `GET /a/b.data` |
| 222 | +- Start a `middleware` |
| 223 | +- Start b `middleware` |
| 224 | +- Run a/b `loaders` in parallel |
| 225 | +- Render HTML `Response` to bubble back up via `next()` |
| 226 | +- Finish b `middleware` |
| 227 | +- Finish a `middleware` |
| 228 | +- Respond to client |
| 229 | +- Finish b `clientMiddleware` |
| 230 | +- Finish a `clientMiddleware` |
| 231 | + |
| 232 | +If we have `clientLoaders` and they don't call server `loaders` (SPA Mode): |
| 233 | + |
| 234 | +- Start a `clientMiddleware` |
| 235 | +- Start b `clientMiddleware` |
| 236 | +- Run a/b `clientLoaders` in parallel |
| 237 | +- _No Response to render here so we can either bubble up `undefined` or potentially a `Location`_ |
| 238 | + - `Location` feels maybe a bit weird and introduces another way to redirect instead of `throw redirect`... |
| 239 | +- Finish b `clientMiddleware` |
| 240 | +- Finish a `clientMiddleware` |
| 241 | + |
| 242 | +If `clientLoaders` do call `serverLoaders` it gets trickier since they make individual server requests: |
| 243 | + |
| 244 | +- Start a `clientMiddleware` |
| 245 | +- Start b `clientMiddleware` |
| 246 | +- Run a/b `clientLoaders` in parallel |
| 247 | + - `a` `clientLoader` calls GET `/a/b.data?route=a` |
| 248 | + - Start a `middleware` |
| 249 | + - Run a loader |
| 250 | + - Render turbo-stream `Response` to bubble back up via `next()` |
| 251 | + - Finish a `middleware` |
| 252 | + - `b` `clientLoader` calls GET `/a/b.data?route=b` |
| 253 | + - Start a `middleware` |
| 254 | + - Start b `middleware` |
| 255 | + - Run b loader |
| 256 | + - Render turbo-stream `Response` to bubble back up via `next()` |
| 257 | + - Finish b `middleware` |
| 258 | + - Finish a `middleware` |
| 259 | +- Finish b `clientMiddleware` |
| 260 | +- Finish a `clientMiddleware` |
| 261 | + |
| 262 | +### Other Thoughts |
| 263 | + |
| 264 | +- Middleware is data-focused, not an event system |
| 265 | + - you should not be relying on middleware to track how many users hit a certain page etc |
| 266 | + - middleware may run once for actions and once for loaders |
| 267 | + - middleware will run independently for navigational loaders and fetcher loaders |
| 268 | + - middleware may run many times for revalidations |
| 269 | + - middleware may not run for revalidation opt outs |
| 270 | +- Middleware allows you to run logic specific to a branch of the tree before/after data fns |
| 271 | + - logging |
| 272 | + - auth/redirecting |
| 273 | + - 404 handling |
| 274 | + |
| 275 | +[rfc]: https://github.com/remix-run/react-router/discussions/9564 |
| 276 | +[client-context]: https://github.com/remix-run/react-router/discussions/9856 |
| 277 | +[single-fetch]: https://remix.run/docs/en/main/guides/single-fetch |
| 278 | +[data-strategy]: https://reactrouter.com/v6/routers/create-browser-router#optsdatastrategy |
| 279 | +[remix-the-web]: https://github.com/mjackson/remix-the-web |
| 280 | +[async-provider]: https://github.com/ryanflorence/async-provider |
0 commit comments