Data fetching & caching in an `elm-land` SPA

Hello!

I’m working on an elm-land SPA, and looking into ways I can reduce the number of loading skeletons and spinners throughout the user experience. I’m here to solicit advice as well as share a solution that seems promising but I haven’t really put it through its paces.

In React I might use swr or @tanstack/react-query to achieve what I’m after, and in Elm I’ve heard of the “store pattern” and elm-fetch.

I haven’t had luck adapting the store pattern to work in elm-land. The direct approach of importing Pages.* stuff (like a dataRequests : Store -> Route params -> List Action presented in the demo store pattern app) into Shared results in circular dependencies that I couldn’t really see how to work around without getting into patching the code-gen. An alternative approach of setting a List Store.Action from each page’s init isn’t quite the same: in order to respond to changes in the store to do waterfall requests (or dependent queries as TanStack calls them) you end up with a lot of boilerplate in my experience—because you can’t dynamically create the List Action based on existing Store, if you want to have A : Action followed by B : Action in one use case, but just do A : Action in another case, you’d need to split A into two different actions, or have it take a Bool parameter. I don’t know if that makes sense :sweat_smile: it’s possible that there’s ways around that I wasn’t seeing.

(I didn’t try out elm-fetch, at a glance it seems like it might have the same limitations.)

The pattern I landed on seems closer to swr from what I understand (without having used that library) and I’m curious if anyone has experience with something similar. It borrows from store pattern but with a bit “looser” types in exchange for less boilerplate and a bit more ease-of-use (at least as far as I’ve experimented with it).

A Store in this approach is just a simple cache, basically Dict Url (WebData Json.Decode.Value), and you can use

Effect.sendStoreRequest : Strategy -> Request a -> Effect msg

in init & update to make requests, and

Store.get : Request a -> Store -> WebData a

to lookup the requested data in view, where Request is essentially the argument to Http.request

type alias Request a =
    { method : String
    , headers : List Http.Header
    , path : List String
    , query : List Url.Builder.QueryParameter
    , body : Http.Body
    , decoder : Decoder a
    }

I removed timeout & tracker because I never use them, and split the url into a bit more structured pieces to make it easier to manipulate.

Here’s an example of a page with multiple requests, an example of pagination, and a waterfall request. And here’s a link to the deployed demo app (the home, posts, and users pages use the cache).

I’m still not sure about a good solution for waterfall requests with this approach. What I ended up with is just emitting an event from view via a custom element when the result of the initial request loads :see_no_evil_monkey: React-brained for sure, but it works.

2 Likes

Added a little debug view of the store to make it clearer what’s going on.

2025-05-27 at 23.48.48 - Lavender Heron

2025-05-28 at 0.03.37 - Copper Bison

a bit “looser” types in exchange for less boilerplate

It feels like a chaotic hack to just store JSON in the model :sweat_smile: but it gets decoded before it hits the store

Json.Decode.succeed (\_ x -> x) -- decode as the "precise" type, but just keep the JSON
    |> Json.Decode.Pipeline.custom req.decoder
    |> Json.Decode.Pipeline.custom Json.Decode.value

so there’s opportunity to log errors, etc., at that point. I guess there’s a risk that you might get that part wrong and the types won’t help you.

Having a hard time coming up with other downsides. And it’s not very much code to move a request from Page level to Store. Maybe memory becomes an issue, compared to a well-typed record Store, if you’re getting lots more JSON than you’re actually using…

The debug view really helped clarify the functionality. Well done and thanks for sharing. :slight_smile:

Ah, I overlooked a serious flaw with this approach: Msg now contains a Decoder, so it is not serializable :disappointed_face: