Learn how to break down a big app into several smaller programs with View.map
Explicitly listing out all the possible states of an app is the main strength of MVU. But as your app grows and gets more features, this strength can become its greatest weakness resulting in a giant single file where everything is mixed together and difficult to maintain.
Take for example a simple app where users create their account by going through several pages: Personal details -> Create password -> etc.
Typically, we would start by putting every single data into the App.Model. This would force us to also handle all the messages, updates, and views in the same file.
App.fs
typeModel={ CurrentPage:int FirstName:string LastName:string EmailAddress:string Password:string ConfirmPassword:string...}typeMsg=| FirstNameChanged ofstring| LastNameChanged ofstring| EmailAddressChanged ofstring| PasswordChanged ofstring| ConfirmPasswordChanged ofstring|...let update msg model =match msg with| FirstNameChanged newValue ->{ model with FirstName = newValue }|...let view model = NavigationPage(){ ContentPage("Form1",...)if model.CurrentPage >=2then ContentPage("Form2",...)if model.CurrentPage >=3then ContentPage("Form3",...)...}let program = Program.stateful init update view
While this is perfectly fine for small apps with few features, typical apps have way more features and it can quickly become unyielding.
Decomposing a big app with View.map
To avoid having everything together in a single Model and Msg type, we can decompose the code into several MVU states with independent Model, Msg, init, update and view functions.
In our example, we can extract most of the code into separate page files, making it much more easier to reason about and maintain.
Thanks to the functional approach of F#, writing a function converting Form1.Msg to App.Msg is as simple as just using the discriminated value "Form1Msg".
View.map Form1Msg (Form1.view model.Form1Model)
Using this View.map function will change the widget Msg type to the common one, which will allow the app to compile.
Now, that we managed to compose the forms' view functions into the app view function, let's see how to implement init and update.
Calling init at the right time
Since in our example, our users are going through a journey composed of several pages to create an account, we need to model this journey. This will allow us to track where the users are right now and also avoid us to instantiate the pages they haven't reached yet.
We can do that with a simple discriminated union.
App.fs
typeJourneyStep=| StepForm1| StepForm2// We add this new DU into the modeltypeModel={ JourneyStep:JourneyStep Form1Model:Form1.Model Form2Model:Form2.Model option }
You might have noticed Form2Model is marked as Option. We changed it because we don't want to initialize its state when the users haven't reach this step yet.
The App's init function is now very simple to write:
App.fs
let init ()={ JourneyStep = StepForm1 // At the start, users are on the first step Form1Model = Form1.init()// We simply call the Form1 init function to initialize it Form2Model = None }// And we put the other steps to None
Here we initialized the Form1.Model, but how do we initialize the other models later on?
For that, we need to know when to initialize them. For example, when clicking a button. We add a new message in App.Model and handle it inside the update function.
App.fs
typeMsg=| NextStep ofJourneySteplet update msg model =match msg with| NextStep step ->let newModel =match step with| StepForm1 ->{ model with Form1Model = Form1.init() Form2Model = None }// If we are back on Form1 means Form2 is no longer available| StepForm2 ->{ model with Form2Model = Some(Form2.init())}{ newModel with JourneyStep = step }
Calling update at the right time
Now that we have both the view and init functions in place, we are just missing the update function.
This is straightforward as we simply need to pass the messages from the App to the corresponding form.
App.fs
let update msg model =| Form1Msg f1 ->{ model with Form1Model = Form1.update f1 model.Form1Model }| Form2Msg f2 ->{ model with Form2Model = Form2.update f2 model.Form2Model.Value }
You might notice we are using ".Value" here on an Option type. While this is not a good practice (because it could result in a crash if value is None), we can assume we won't ever receive messages from Form2 when this one is not loaded.
If you wish to have a safer code, you could handle the error case (None) by either ignoring the message for Form2 or tracking it in another way (logging, analytics, etc).
Final result
Voilà! We now have all the pieces to compose the full app together from independent pages.