Solving Elm Router "Double Update" Problem

I found some older discussions on this issue, but they did not really provide a clear answer:

It turns out I discovered a simple solution, so I am writing it down in case I forget, or in case someone else finds it useful.

Imagine we have an expensive parseAppRoute function that performs many effects. We do not want it to run twice: once for Navigate and again for UrlChanged. (I am ignoring LinkClicked in this explanation, since in my app I only use Navigate, but the principle is the same.)

The idea is to keep track of a boolean flag called isInternal that indicates whether the URL change originated from inside the app or from an external action such as the browser’s back/forward buttons. By default this flag is False, because back/forward navigation can happen at any time.

Whenever I change the route from inside the app, I set isInternal to True. Then, when the follow-up UrlChanged message arrives, I check the flag:

  • If it is True, I ignore the message and reset the flag to False.
  • If it is False, I know the change came from the browser (back/forward), so I call parseAppRoute.

This way we avoid calling handling the route change twice.

On initial page load, the route is handled in init, so there is no issue there either.

Here is an example implementation:

parseAppRoute : String -> (Route, Cmd Msg) 
parseAppRoute url =
   let
      newRoute = urlStringToRoute url
   in
      (newRoute, getCmdFrom newRoute)

cmdFromRoute : Route -> Cmd Msg
cmdFromRoute route =
    -- perform expensive side effects


init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ url key =
    let
        (initRoute, initCmd) = parseAppRoute url
    in
    ( { route = initRoute
      , isInternal = False
      , key = key
      }
    , initCmd
    )


-- UPDATE

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UrlChanged url ->
            if model.isInternal then
                -- Ignore the UrlChanged that we triggered ourselves;
                -- then reset the flag.
                ( { model | isInternal = False }, Cmd.none )

            else
                -- Triggered by browser back/forward navigation
                let
                    (newRoute, newCmd) = parseAppRoute url
                in
                ( { model | route = newRoute }, newCmd )

        Navigate route ->
            let
                href = toUrlString route
                newRouteCmd = cmdFromRoute route
            in
            ( { model
                | isInternal = True -- Mark this as an internal change
                , route = route
              }
            , Cmd.batch [ Nav.pushUrl model.key href, newRouteCmd ]
            )

        LinkClicked req ->
            case req of
                Browser.Internal url ->
                    -- Treat internal clicks like Navigate
                    let
                        (newRoute, newCmd) =
                            parseAppRoute url
                    in
                    ( { model | isInternal = True, route = newRoute }
                    , Cmd.batch
                        [ Nav.pushUrl model.key (Url.toString url)
                        , newCmd
                        ]
                    )

                Browser.External href ->
                    ( model, Nav.load href )

        None ->
            ( model, Cmd.none )

I hope to hear from others if they reach the same conclusion. Feel free to ask me anything as well.

It seems to me (maybe I am wrong), that you would achieve the same effect if you just parse url and generate effects in the branch for UrlChanged message. I expect that this message would be delivered only once as a follow up to LinkClicked or Navigate. In other words just after the Nav.pushUrl command is executed.

The way you wrote it, you have parsing urls and calculating effects on three different branches and therefore this parsing and calculation might get run more than once, so you must add a flag isInternal. If you just let the messages LinkClicked and Navigate return unchanged model with the effect Nav.pushUrl, than in the next update call UrlChanged will be delivered and only then you actually change the route inside the model and perform necessary effects.

HTH