The trick is to apply the immutable function(s) again when you get new input/events.
For example, you can model all events using a list. Each time you get an event or input, you append it to that list. (Yes, there is mutation happening, but this is in the framework, outside of the scope of the immutability constraint)
Your program then can be a function that takes that list with the history of inputs as input. So that immutable function can return a different value because it gets a different input.
Elm does the aggregation differently: Instead of that list, it uses a state model value threaded through applications of the update function.
Elm builds a chain of applications of the update function.
I like the REPL analogy. I also tend to think of it in terms of an ASP.NET MVC app that doesn’t use two-way model-binding–data comes into the update function (router), which selects the correct message (controller) to updates the model (application state), and then evaluates a new version of the view (model binding).
If you’re from C# you might enjoy this series I wrote on how to do monads in C#. It’s shameless self-promotion, but it does honestly make my day-to-day in C# so much easier.
Also, functions like Http.get, rather than initiating the call themselves, are basically just constructors for objects like {url = "http://foo", callback = someFunction } (pseudocode) Which are then handed to the runtime which does the actual request.
Functional languages also use IO Monads (called Task in Elm, Async in F#) which are chainable with an API similar to Task in C#. Unlike C# Tasks though which make the request as soon as they are created, you construct your chain of Elm Tasks and hand them to the runtime, or in F# (since it allows impure code) you pass it to Async.RunSynchronously() (or give it to your server framework) which makes all the calls.