DEV Community

Cover image for Tour of an Open-Source Elm SPA
Richard Feldman
Richard Feldman

Posted on • Updated on

Tour of an Open-Source Elm SPA

People often ask me if I can point them to an open-source Elm Single Page Application so they can peruse its code.

Ilias van Peer linked me to the Realworld project, which seemed perfect for this. They provide a back-end API, static markup, styles, and a spec, and you build a SPA front-end for it using your technology of choice.

Here's the result. I had a ton of fun building it!

4,000 lines of delicious Elm single page application goodness

Fair warning: This is not a gentle introduction to Elm. I built this to be something I'd like to maintain, and did not hold back. This is how I'd build this application with the full power of Elm at my fingertips.

I gave a talk at Elm Europe about the principles I used to build this, and I highly recommend watching it! It's called Scaling Elm Apps.

If you're looking for a less drink-from-the-firehose introduction to Elm, I can recommend a book, a video tutorial, and of course the Official Guide.

Routing for User Experience

I went with a routing design that optimizes for user experience. I considered three use cases, illustrated in this gif:

The use cases:

  1. The user has a fast connection
  2. The user has a slow connection
  3. The user is offline

Fast Connection

On fast connections, I want users to transition from one page to another seamlessly, without seeing a flash of a partially-loaded page in between.

To accomplish this, I had each page expose init : Task PageLoadError Model. When the router receives a request to transition to a new page, it doesn't transition immediately; instead, it first calls Task.attempt on this init task to fetch the data the new page needs.

If the task fails, the resulting PageLoadError tells the router what error message to show the user. If the task succeeds, the resulting Model serves as the initial model necessary to render 100% of the new page right away.

No flash of partially-loaded page necessary!

Slow Connection

On slow connections, I want users to see a loading spinner, to reassure them that there's something happening even though it's taking a bit.

To do this, I'm rendering a loading spinner in the header as soon as the user attempts to transition to a new page. It stays there while the Task is in-flight, and then as soon as it resolves (either to the new page or to an error page), the spinner goes away.

For a bit of polish, I prevented the spinner from flashing into view on fast connections by adding a CSS animation-delay to the spinner's animation. This meant I could add it to the DOM as soon as the user clicked the link to transition (and remove it again once the destination page rendered), but the spinner would not become visible to the user unless a few hundred milliseconds of delay had elapsed in between.

Offline

I'd like at least some things to work while the user is offline.

I didn't go as far as to use Service Worker (or for that matter App Cache, for those of us who went down that bumpy road), but I did want users to be able to visit pages like New Post which could be loaded without fetching data from the network.

For them, init returned a Model instead of a Task PageLoadError Model. That was all it took.

Module Structure

Module structure: Data, Page, Request, and Views directories, along with Main.elm, Ports.elm, Route.elm, and Util.elm

We have over 100,000 lines of Elm code in production at NoRedInk, and we've learned a lot along the way! (We don't have a SPA, so our routing logic lives on the server, but the rest is the same.) Naturally every application is different, but I've been really happy with how well our code base has scaled, so I drew on our organizational scheme when building this app.

Keep in mind that although using exposing to create guarantees by restricting what modules expose is an important technique (which I used often here), the actual file structure is a lot less important. Remember, if you change your mind and want to rename some files or shift directories around, Elm's compiler will have your back. It'll be okay!

Here's how I organized this application's modules.

The Page.* modules

Examples: Page.Home, Page.Article, Page.Article.Editor

These modules hold the logic for the individual pages in the app.

Pages that require data from the server expose an init function, which returns a Task responsible for loading that data. This lets the routing system wait for a page's data to finish loading before switching to it.

The Views.* modules

Examples: Views.Form, Views.Errors, Views.User.Follow

These modules hold reusable views which multiple Page modules import.

Some, like Views.User, are very simple. Others, like Views.Article.Feed, are very complex. Each exposes an appropriate API for its particular requirements.

The Views.Page module exposes a frame function which wraps each page in a header and footer.

The Data.* modules

Examples: Data.User, Data.Article, Data.Article.Comment

These modules describe common data structures, and expose ways to translate them into other data structures. Data.User describes a User, as well as the encoders and decoders that serialize and deserialize a User to and from JSON.

Identifiers such as CommentId, Username, and Slug - which are used to uniquely identify comments, users, and articles, respectively - are implemented as union types. If we used e.g. type alias Username = String, we could mistakenly pass a Username to an API call expecting a Slug, and it would still compile. We can rule bugs like that out by implementing identifiers as union types.

The Request.* modules

Examples: Request.User, Request.Article, Request.Article.Comments

These modules expose functions to make HTTP requests to the app server. They expose Http.Request values so that callers can combine them together, for example on pages which need to hit multiple endpoints to load all their data.

I don't use raw API endpoint URL strings anywhere outside these modules. Only Request.* modules should know about actual endpoint URLs.

The Route module

This exposes functions to translate URLs in the browser's Location bar to logical "pages" in the application, as well as functions to effect Location bar changes.

Similarly to how Request modules never expose raw API URL strings, this module never exposes raw Location bar URL strings either. Instead it exposes a union type called Route which callers use to specify which page they want.

The Ports module

Centralizing all the ports in one port module makes it easier to keep track of them. Most large applications end up with more than just two ports, but in this application I only wanted two. See index.html for the 10 lines of JavaScript code they connect to.

At NoRedInk our policy for both ports and flags is to use Value to type any values coming in from JavaScript, and decode them in Elm. This way we have full control over how to deal with any surprises in the data. I followed that policy here.

The Main module

This kicks everything off, and calls Cmd.map and Html.map on the various Page modules to switch between them.

Based on discussions around how asset management features like code splitting and lazy loading have been shaping up, I expect most of this file to become unnecessary in a future release of Elm.

The Util module

These are miscellaneous helpers that are used in several other modules.

It might be more honest to call this Misc.elm.

Other Considerations

  • With server-side rendering, it's possible to offer a better user experience on first page load by using cookie-based authentication. There are security risks on that path, though!
  • If I were making this from scratch, I'd use elm-css to style it. However, since Realworld provided so much markup, I ended up using html-to-elm to save myself a bunch of time instead.
  • There's a beta of elm-test in progress, and I'd like to use the latest and greatest for tests. I debated waiting until the new elm-test landed to publish this, but decided that even in its untested form it would be a useful resource.

I hope this has been useful to you!

And now, back to writing another chapter of Elm in Action.

Top comments (32)

Collapse
 
happysalada profile image
happysalada

Could detail a little bit your build process ?
(I couldn't find any real ressources on the build process for elm apps)
Checking your app you use elm-live, but I couldn't find how elm-live would do gzipping.
Does this mean in production, you rely on the server to gzip files ?

Also, since the css was provided to you, you didn't need to build it or do anything with it. There could be another advantage of a build tool. Do you use elm-live at NoRedInk too ? (I couldn't find anywhere, where you could customize, minify and gzip the css with elm-live)

Collapse
 
marcelolaza profile image
Marcelo Lazaroni

Awesome! And you wrote the whole thing in 5 hours!

This is very informative. I think this definitely deserves a mention on Elm roadmap under "How do I make a single page app".

Great Job.

Collapse
 
rtfeldman profile image
Richard Feldman

haha I didn't actually write the whole thing in 5 hours 😄 - I just didn't want to publish it until it was done, so I developed it locally and then copied everything over at the last minute after creating the repo. 😉

(It actually took closer to a week.)

Collapse
 
slashmili profile image
Milad • Edited

Thanks for sharing! It's really great and it's already answered a lot of my questions :)

Just one thing that I noticed is that Main.elm file is getting long and things like ProfileLoaded and etc are residing there.

Is there a way to forward updates to its own sub module?

Collapse
 
sandeepdatta profile image
Sandeep Datta

Richard, can you add an explanation for why you rearchitected everything (removed the Requests, Views and Data folders) and put everything in the root folder? I have seen the video in which you say you regret separating out views etc. but you do not give any reasons. Please consider adding some explanation here instead. Thanks.

Collapse
 
rtfeldman profile image
Richard Feldman

I started to write this up, but realized I'd much prefer to explain the changes in a talk rather than a post, so that's what I'm going to be doing at Oslo Elm Day in February!

I'll circle back to this post and update it to point to the talk once it's out.

Collapse
 
carlfredrikhero profile image
Carl-Fredrik Herö

Here is the Oslo Elm day talk: youtube.com/watch?v=RN2_NchjrJQ

Collapse
 
sandeepdatta profile image
Sandeep Datta

Looking forward to it, thanks again!

Collapse
 
dnk8n profile image
Dean Kayton

Very cool. Would it be possible to play with this locally? Can I set up a RealWorld backend and have this talk to it?

I tried, but couldn't figure out how to configure the backend url/endpoint. Is there a settings file I need to place somewhere?

Collapse
 
coderaguet profile image
Code Raguet • Edited

Thanks, Richard, this is awesome and congrats for your talk at Elm Europe 2017.

This doc and github repo were updated in 2019, is this still the way you would actually coded this app? are there changes in the way of doing this stuff due to elm updates?

I'm asking because of this comment: in Main.elm
github.com/rtfeldman/elm-spa-examp...

Thanks again

Collapse
 
arkadefr profile image
aRkadeFR

Hello, excellent article and example to learn ELM.
I'm a bit confused by the separation of Views and Page. Some Page have only html and are not Views. There's also a Page module included in the Views module. Can you explain a bit more this separation?

Collapse
 
dmattia profile image
David Mattia

I can try to help here.

Richard's architecture assumes there is a clear mapping between a "Route" and a "Page". Main.setRoute takes in a Route and attempts to find a Page to set on the main state. At any given time, the PageState in the model determines how the page should look at a high level. So at a high level, "/login" maps to Route.Login in the router, which maps to Page.Login in setRoute.

However, there is a considerable amount of reused view logic that is shared between pages (or even other projects). This logic is moved into Views.* modules.

The Views.Page module could probably be named something else like View.CurrentPage, but the purpose of its frame function is to create the complete view for a user (combines the main content area from the Page's view method, the header, and the footer).

Collapse
 
globus68 profile image
globus68

Hi Richard!

We are using your Elm SPA Example as basis for an application. We have a problem we need some guidance on: We need to trigger an API request after a sub page has been succesfully initialized and loaded.
( PrognosisLoaded (Ok subModel), _ ) ->
{ model | pageState = Loaded (Prognosis subModel) } => Cmd.none
As we cannot see a way to do this within the Prognosis subPage, our first attempt was to fire a command in place of the Cmd.none in Main.elm. However the message handling the command will be attached to the Main.elm module making it hard to update the subModel of the Prognosis subPage.

How can this be accomplished?

Best regards
globus68

PS! I read in the mailing list of your upcoming Elm book (which I of course has ordered), that you just have been married. My best wishes to both of you!
DS!

Collapse
 
dalen profile image
Erik Dalén

Thanks for the nice example, I'm borrowing some ideas for my open source Elm SPA I'm developing as I'm still a Elm newbie.

But I'm wondering about the use of Task,map instead of Cmd.batch, it seems that causes all requests to be done in sequence instead of in parallel. For example the list of articles and the list of tags are loaded after each other instead of in parallel. Is there anyway to fix that and keep using Tasks?

Collapse
 
rtfeldman profile image
Richard Feldman

Not yet, but I expect there will be something in Http to parallelize HTTP requests in the future.

Since it's just a performance optimization and performance is fine as-is, I thought it'd be prematurely optimization to go out of my way to use Cmd.batch instead, but in a world where I can parallelize via Http I'd reach for that instead of Task.map2 assuming it would be an easy upgrade. :)

Collapse
 
mchen7588 profile image
mchen7588

will you still be updating this article or posting a new article on the Elm 0.19 version of this app?

Collapse
 
paulen_8 profile image
🌹 • Edited

Would love to see that as well if Richard gets a chance.
Appreciate all your awesome contributions to the community Richard!

Collapse
 
jdunruh profile image
John Unruh

Thanks for posting this. I am currently working on an SPA, and I have run into scaling problems with my update function and model. Your example shows exactly how to split update and the model across pages, and how to solve some other problems I have seen. This is the example needed to go past the good but simple intro material and to a real application.

Collapse
 
wintvelt profile image
wintvelt

This is awesome Richard. Especially your setup with variations for fast-slow-no connection with Task is great. Thanks for sharing!

Collapse
 
ben profile image
Ben Halpern

This is so amazing, Richard.

Collapse
 
yevheniygloba profile image
YevheniyGloba

Hello, thanks a lot for the great article.
I have a question regarding handling a lot of messages in the Main file for update section: type Msg = SetRoute (Maybe Route) | HomeLoaded (Result PageLoadError Home.Model) ...

Imagine that you have a lot of these messages (in our example we have 32 messages, and 31 of them are like HomeLoaded (Result PageLoadError Home.Model) .. and HomeMsg Home.Msg)
And here we have a problem with building our app - it takes up to 50 seconds to build app.

Could you help us? Could you give us some help?

Collapse
 
rtfeldman profile image
Richard Feldman • Edited

There's a bug in the 0.18 compiler that has a big performance regression when you have case-expressions with many branches (as I recall, 32 is where it kicks in). The bug will be fixed in 0.19, but in the meantime you can split some of the messages out into a separate union type just to split the one big case-expression into two case-expressions.

Does that make sense? Happy to elaborate if it's unclear!

Collapse
 
warren2k profile image
Warren Wise

You, sir, are a gem!