An Alternative Way to structure your SPA

I know there is a Project to implement an Elm SPA.
But I have not bothered with it when I started working on my own SPA and simply used Browser.application to write my own approach to developing Elm SPA.

I wanted to share this approach here, since it might be of interest and different to Elm SPA.

My SPA is Open Source and you can inspect it’s entire Codebase here: Demnet/ui - OAuth

A Datatype based SPA

At the center of my SPA sit the Datatypes handled by the SPA.

In my src folder I have a folder called DemNet (that’s the project’s name) where I have 1 Module for each Data Type handled by the SPA.

Each Module exposes a Type, a decoder and an encode function.
As well as values otherwise relevant for the Data Type.

The Cache

All of these Types are used by the src/DemNet/Cache.elm Module, which takes all of these types and creates a Cache for each.

type alias Cache =
    { users : Dict Username User
    , languages : Dict Lang Language
    , elections : Dict Int Election
    , messages : Dict String Message
    , drafts : Dict Int Message
    }

Each Cache is just a dict from a type specific unique identifier (i.e. the username, the id, …)
to a value.

The Page

In the Page module, I can now, since the data shown on the page will be stored in the Cache,
simply use the keys to the data I need in the Cache to address them.

type Page
    = Welcome
    | Vote Election.Id
    | Feed { pageNum : Int, pageSize : Int }
    | Read Message.Id
    | Write Message.Id
    | Profile Username
    | Error Int String

Instead of storing the state of the Page in the page, I only describe what should be in the Page and leave the rest to the Cache.

The Model

In my Model I thus now store the following two values:

type alias Model =
     { page : Page
     , cache : Cache
     , ...
     }

And when I render this Model,
I first look at the page and then fetch the
data from the Cache that is referred to by the
page.

The Advantages

This approach has several advantages in my mind:

  1. Because the page, that is currently shown, has nothing to do with the data that is shown within it, I can modify a data entry, regardless of the currently viewed page. And I can change my page, regardless of whether or not I have the data required for this.
    I do not throw out any messages in update, because of an invalid page.
  2. Because I keep a Cache of all the data that the client received, I can also work offline or with short network issues. Because every page you have visited before, is still in RAM (or localstorage if I ever store the cache there).

The Disadvantages

But I do not want to appear, as if this is the perfect or good way to write a SPA.
There are disadvantages:

  1. If the Cache is not regularly cleaned, RAM will be wasted and eventually small devices will have performance issues.
  2. Many more, that I cannot yet foresee, since this is not an approach that I have been running in Production for many months, but only just developed.

My best regards to all of you!
May this post have inspired you at least, to think about alternative ways of creating Single Page Applications.
If I have just reinvented a wheel, please let me know, so I may stop embarrassing myself and start to learn about where this approach was/is used and why.

11 Likes

This is similar to how I’ve been structuring my apps, and it’s worked well for me. I’ve been referring to it as keeping my model “normalized” (term borrowed from database lingo). A few more things I’ve found about it:

  • I sometimes find that I will also have a copy of a model in my Page in addition to the Cache. This works well for me since it forces a separation of the “saved” version in the Cache and the “unsaved” version in the Page.
  • You can write a function from the Page to the required cache keys to help you know what is able to be purged from the cache. This can also be used to fetch resources based on the Page, and the logic for fetching can be consolidated so you don’t have to worry about it in your update function.

My Cache is not a cache of the Model.
But the Model uses the Cache.

I find that one advantage of this is, that there is a clear import structure in my codebase:

                     
Main.elm -> Page -> Cache -> <DataType>

We have run this in production for a few years now. It works great. The memory usage is not bad (we don’t clean the cache in any way) but that will obviously vary based on your application.

A few notes:

  • We call the Cache Store (but that’s only a cosmetic detail)
  • We let each Route (your Page) module dictate StoreActions on route change with a MyPage.updateForRoute, so if you go from eg. Users list (which needs FetchUsers) to Users detail for ID 50 (which needs FetchFullUser 50), all the fetches happen automatically and declaratively, and only if they’re not loaded already
  • We use phantom types for additional safety for the various ID types (UserId that can’t be swapped with ElectionId by mistake)
  • Because of the ID wrapping in custom types, we use turboMaCk/any-dict instead of a normal Dict
  • We use krisajenkins/remote-data for the items so that we can track what is loaded already and what is not

With that, our store looks roughly like:

type alias Store =
    { dashboards : WebData (IdDict DashboardIdTag Dashboard)
    , dashboardsUserSettings : WebData DashboardsUserSettings
    , questions : IdDict QuestionCodeTag (WebData Question)
    , ...
    }

The position of WebData in the field type tells you at what granularity does it get fetched.
Eg. in the above, dashboards and dashboardsUserSettings get loaded all at once, but for questions each question gets loaded separately.

All in all, we’re very satisfied with this approach.

3 Likes