Build a Web App with Oats~i – Lesson 2: Reactivity and Data Manager

oatsi

Oats〜i

Posted on August 24, 2024

Build a Web App with Oats~i – Lesson 2: Reactivity and Data Manager

Welcome to the second lesson of the Build a Web App with Oats~i series, where we’re now building a real project using Oats~i and learning some of the core concepts of the web framework.

In the previous lesson, we built a simple web app with two fragments, one for showing random cat images and another for searching movies using publicly available APIs. We only wired the interfaces, and left the reactivity bit for today’s lesson.

If you didn’t read the previous tutorial, find it here. This is a continuation of that lesson.

Also, I strongly recommend you clone and run the final project beforehand. This helps shorten the written tutorial and have it focus mostly on explaining what’s happening as far as Oats~i is concerned.

Find the completed project for lesson 2 here: https://github.com/Ian-Cornelius/Lesson-2---Reactivity-and-Data-Manager

Run npm install, then npm run dev to run it.

In the http cats fragment, click on “meeow” to get a new random cat image based on http status codes. In the movies fragment, search for a movie and see the results get populated.

Getting the API Key for Movies

To run the movies API, you need to get a key from themoviedb.org. This video will help you do this in under five minutes https://www.youtube.com/watch?v=FlFyrOEz2S4 (Use the access token instead, and replace it at the getReqBody method, where we have ${\_CONFIG.MoviesAPIKey}).

Now, let’s get into it.

Adding Reactivity to Our Web App

We add reactivity to an Oats~i web app using the data manager. This is a built-in utility that allows you to perform data mutations and have the process and result of these mutations broadcasted to only the views you want to respond to these changes.

That means, instead of data and views being strongly coupled together as long as they’re defined in the same file, Oats~i allows you to choose which views you want to bind to what data, whether they’re defined in the same file or not.

Let’s look at it practically using the example project.

The http_cats_main_fragment.js File

Open the http_cats_main_fragment.js file and look for the following bits of code.

Definition of the HTTP Cats Data Model

Just above where we define the HTTPCatsMainFragment class, we have the following code:


/**
 * @typedef HTTPCatsModel
 * @property {string} img
 */

Enter fullscreen mode Exit fullscreen mode

The code above uses JSDoc to define a type called HTTPCatsModel, with a property img of type string. Doing this is very important if we are to have the data manager accurately interpret and represent our types as we use it.

You’ll see this application shortly.

(A typescript type definition exported from a typescript declaration file [d.ts] will also work)

Setting Up Our Data Manager in the Constructor

It’s highly recommended that you set up your data manager in the constructor of an Oats~i fragment or view panel. This ensures that other functions of the data manager, such as server-side hydration, happen right before the fragment renders its view, so that any attached view managers find it ready.

We’ll handle server side rendering later.

For now, inside the HTTPCatsMainFragment class definition, let’s look at the following lines of code.


constructor(args){

        super(args);

        //set up list of random codes
        this.randomCodesList = [100, 101, 102, 103, 200, 201, 202, 203, 204, 205, 206, 207, 208, 214, 226, 
                                300, 301, 302, 303, 304, 305, 307, 308, 400, 401, 402, 403, 404, 405, 406,
                                407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 420, 421, 422,
                                423, 424, 425, 426, 428, 429, 431, 444, 450, 451, 497, 498, 499, 500, 501,
                                502, 503, 504, 506, 507, 508, 509, 510, 511, 521, 522, 523, 525, 530, 599];

        //create data manager instance
        /**
         * @type {DataManager<HTTPCatsModel>}
         */
        this.httpCatsDataManager = new DataManager({

            primaryLifeCycleObject: this.getLifeCycleObject(),
            masterAPIOptions: {

                reqUtils: new LifecycleRemoteRequestUtils(this.getLifeCycleObject())
            }
        });

        //set up the data manager
        this.setUpDataManager();

        //set up the view manager
        /**
         * @type {StandardViewManager<HTTPCatsModel, "MODEL_ROOT">}
         */
        this.httpCatsStandardViewManager = null;
        this.setUpViewManager();
    }

Enter fullscreen mode Exit fullscreen mode

Let’s break this code down, looking into the important lines of code concerning reactivity and data management.

The instance variable randomCodesList just holds a list of all valid codes we can use for our http cats API. It’s only for supporting the app’s “business” logic.

The instance variable httpCatsDataManager is the first important bit of code as far as reactivity is concerned. It holds the instance of our data manager, which manages data specifically of the type HTTPCatsModel that we had defined earlier in JSDoc format.

We help intellisense understand this using the JSDoc directive


/**
  * @type {DataManager<HTTPCatsModel>}
  */

Enter fullscreen mode Exit fullscreen mode

Placed right above the httpCatsDataManager definition. This is a very important step that will make using the data manager, at least in a JavaScript environment, much easier for you. All methods will now be able to infer the right types, making performing data mutations within various scopes much easier, improving the developer experience.

To create this data manager instance, we invoke new on the DataManager class, and pass in the lifecycle object and request utils instance that the data manager will use.

Lifecycle Awareness

Data managers in Oats~i are lifecycle aware. This means that they operate only when the fragment or view panel they’re attached to is active/alive. If we are to navigate away from our http cats main fragment, the data manager, using the lifecycle object/instance, will notice the destruction of its attached fragment and perform the necessary clean up to ensure we don’t have any null pointers or undesired side effects from ongoing mutations.

So, you don’t have to worry about lifecycle management.

Request Utils

The final crucial constructor argument for data manager is the request utils instance, where we use the LifecycleRemoteRequestUtils class to give the data manager a request util that is also lifecycle aware, in context of the attached fragment.

A request util in Oats~i is a utility that makes network requests, using XMLHttpRequest. It wraps up most of the boiler plate, making network calls much easier to invoke.

You can use the Standard or Lifecycle variant of remote request utils, with the Lifecycle variant being the most recommended because it automatically handles lifecycle events for you, aborting running network calls or skipping their callbacks in case it notices the attached fragment is no longer running.

Next, we call the setUpDataManager() method, which we’ll break down next.

Define and Implement the setUpDataManager() Method

Just after the constructor definition, we have setUpDataManager() method. This method is user defined, not a standard method provided in Oats~i. We’re using it to finalize setting up our data manager, specifically, adding the necessary network interface that will allow us to access our API from the data manager.

The code looks something like this.


setUpDataManager(){

        const getDataManager = () => this.httpCatsDataManager;
        this.httpCatsDataManager.setScopedAPIOptions("MODEL_ROOT", {

            MODEL_ROOT: {

                networkInterface: {

                    async getReqBody(reqAddr, updatedModel, mutation, completeOldModel){

                        return {

                            reqMethod: "GET",
                            responseType: "blob"
                        }
                    },
                    async onDataLoadPostProcess(reqAddr, response, newData, oldData, mutation, mappedDataId, extras){

                        //create url from blob
                        const url = URL.createObjectURL(response)
                        const finalData = {};
                        getDataManager().spawnPartialShellModel("img", url, finalData, null);
                        return {

                            data: finalData,
                            response: response
                        }
                    },
                    onDataLoadError(reqAddr, response, newData, oldData, mutation){

                        return {

                            response: response
                        }
                    }
                }
            }
        });
    }

Enter fullscreen mode Exit fullscreen mode

Let’s break down this code.

Oats~i’s data manager assumes that the data it manages must have an external resource its sourced from, and other CRUD operations are performed at. To access this resource, the data manager uses a combination of the request utils instance we gave it and a network interface to make the appropriate requests that the developer needs.

This network interface allows the request utils to get the request body and other information such as headers and request method, post process the response, and communicate the API errors in case of a problem.

In our project, we set up a network interface that will allow us to make the right request to our http cats api, and have the desired result returned.

To do this, we use the data manager method setScopedAPIOptions which allows us to set a network interface for a given scope of our data (more on scopes later).

So, we invoke this method and provide our network interface, within which we implement three methods: getReqBody, onDataLoadPostProcess, and onDataLoadError.

The getReqBody method gets the parameters of our network request. This includes the request body, headers, response type, request method, and any other parameter needed to make a successful request.

In our case, we need to specify the request method as “GET” and tell XMLHttpRequest that the expected response type is a blob.

The onDataLoadPostProcess method allows us to post process the response from our server/API, and turn it into our data manager’s scoped model. In our case, the API responds with an image blob which we have to transform into an image url that we’ll use to show the image in our view.

After creating a local url from the blob, we use the data manager method spawnPartialShellModel() to create an object of type HTTPCatsModel that has the img key set to the url value.

This method is powerful for creating and expanding a model based on values of its specific keys. We’ll explore it and many other methods in the advanced lessons involving the data manager. For now, that’s all you need to know.

Finally, onDataLoadError captures any API error responses and allows us to format it in a way that suits our application, if we want to. In this example, we’re just providing the error response as given by the server.

And that’s just about it when it comes to defining a network interface for any of your data manager instances. However, there’s one very important thing we haven’t touched on yet.

Scoping

Scoping is the most important concept you need to understand if you’re to fully utilize and understand the data manager in Oats~i.

The data manager treats your model as a scoped unit of data, meaning it can be broken down from its primary form to other smaller forms that represents bits of the data. Let’s take our HTTPCatsModel for instance.

It’s basically an object of the type:


{
    img: string
}

Enter fullscreen mode Exit fullscreen mode

The data manager views it as an object breakable into the scopes:

MODEL_ROOT -> which represents the whole model, ie.

{

img: string

}

“img” -> which represents a value of string.

You can view a scope as a dot connected path of keys that lead to a value in your model.

So, if say my HTTPCatsModel also had another key in the main object of type:


{
    …
myOtherKey: {
        innerKey: string
               }
}

Enter fullscreen mode Exit fullscreen mode

We’d have new scopes defined as:

myOtherKey -> {

    innerKey: string

       }
Enter fullscreen mode Exit fullscreen mode

myOtherKey.innerKey -> string

So, how does this apply to our data manager, first looking at the network interface?

From our example project, you’ll notice that the network interface is scoped. This means that, inherently, this interface is used to make network calls for data of the type referred to by the scope.

In our case, our setScopedAPIOptions() call to the data manager uses the scope “MODEL_ROOT” to mean the network interface we’ve set up will be used for network calls affecting the MODEL_ROOT scope or our whole model.

By doing this, the data manager also adjusts the type of data it expects when onDataLoadPostProcess returns, in our case, inferring it as our whole model or an array of our complete model, if our API returns a list.

If we were to use “img” as our scope for the data manager, the inferred return type for our data in the method onDataLoadPostProcess would have been string (data manager doesn’t infer a string array because the scope is not MODEL_ROOT”. Only MODEL_ROOT scopes can also return an array).

The data manager allows you to set a network interface for each valid scope of your model. When you request a mutation that involves network activity, it will use the network interface for that scope to get the parameters needed to make a successful request and post process the response to the expected type.

So now, we have a way of accessing our http cats API, through the “MODEL_ROOT” scoped network interface, which will return results that will affect our whole model.

Set Up Our View Manager

With the network interface set, our data manager is ready to load data from the network and update its internal model to the returned data/response. However, there’s one bit left.

How will we know when any of these mutations are happening?

That’s where the view manager comes in.

A view manager is an Oats~i utility that manages a set of views whose dynamicity is dependent on the state or mutation of data held by the data manager, within that scope. So, view managers are scoped as well, to give you granular control over the reactivity of your view to data changes.

What this means is that if we’re to load data for the scope “MODEL_ROOT”, which will essentially get us a new cat image, the data manager will broadcast this information to any view manager attached to that scope (MODEL_ROOT) or its children or direct parent, and have these view managers trigger view updates that will tell the user what the app is doing.

Let’s view this practically.

In our project, after the call to setUpDataManager() in our constructor, we initialize an instance variable for httpCatsViewManager then call the setUpViewManager() method.

Note the type we assign to the instance variable httpCatsViewManager. It’s a standard view manager of the same model as our http cats data manager (HTTPCatsModel), but for the scope “MODEL_ROOT”.

What this means is that this view manager will only react to data changes or mutations in the MODEL_ROOT scope or its children (keys nested inside the referenced model), which in this case will be “img.”

Let’s look at the setUpViewManager() method.


setUpViewManager(){

        //create the instance
        this.httpCatsStandardViewManager = new StandardViewManager(this.httpCatsDataManager, {

            scope: "MODEL_ROOT",
            id: "HTTPCatsStandardViewManager",
            viewOptions: {

                parentNodeID: "cats-results",
                reinflateContentViewNodeID: "new-cat",
                reinflateRootViewOnNewModel: true
            },
            lifecycleOptions: {

                instance: this.getLifeCycleObject()
            }
        });

        //register hooks
        this.httpCatsStandardViewManager.registerViewDataHooks({

            root: {

                builder: {

                    inflateRoot: (data) => {

                        return {

                            inflatedView: require("../views/new-cat/new_cat.hbs")(data)
                        }
                    },
                    onViewAttach: (modelId, data, viewNode, mappedDataId, extras) => {


                    },
                    onViewDetach: (modelId, data, viewNode, mappedDataId, completeCb) => {

                        completeCb();
                    }
                },
                prePostRootAttachHooks: {

                    onMutate: (modelId, mutation, newData, oldData, parentNode, extras) => {

                        //Show loading
                        //@ts-expect-error
                        parentNode.insertAdjacentHTML("beforeend", require("../views/loading-ui/loading_ui.hbs")({ wittyMsg: "Just a meow-ment" }));
                    },
                    onCommit: (modelId, mutation, newData, oldData, parentNode, extras) => {

                        //remove loading
                        parentNode.removeChild(parentNode.querySelector("#loading-ui"));
                    },
                    onCancel: (modelId, mutation, newData, oldData, parentNode, response, extras) => {

                        //remove loading
                        parentNode.removeChild(parentNode.querySelector("#loading-ui"));
                    },
                    onError: (modelId, mutation, data, failedData, response, parentNode, retryCb, extras) => {

                        //not needed
                    }
                }
            }
        }, null);

        //set this view manager in data manager for the "MODEL_ROOT" scope
        this.httpCatsDataManager.setViewManager("MODEL_ROOT", this.httpCatsStandardViewManager, null);
    }

Enter fullscreen mode Exit fullscreen mode

Let’s break this code down.

Instantiating the View Manager

The first block of code creates a new instance of the StandardViewManager class.

View managers in Oats~i are currently of two variants. A standard view manager and a list view manager.

A standard view manager manages standard views with only one content node i.e only one main view.

A list view manager manages list views with several content nodes or more than one content view.

Therefore, if your data will only present and manipulate one “piece” of data, a standard view manager is an excellent choice for intercepting mutations and triggering view updates based on that.

However, if your data will be an array or list, a list view manager will be the best choice to not only intercept mutations but also do so at an item-to-item level.

In our http cats scenario, we’re only getting a single image from the server to update our model. Therefore, a standard view manager is more than enough for our needs.

We instantiate by passing a few crucial arguments such as:

  • scope: This is the scope of data that the view manager will react to. In our case it’s the MODEL_ROOT (note how it mirrors the scope we gave in the JSDoc type definition, to allow intellisense to work properly).
  • id: (optional) Gives the view manager an id. If not provided, the view manager will be provided an automatic one.
  • viewOptions: an object which contains:
    • parentNodeID: the ID of the parent node which encompasses our standard view, or the view within which we’ll be rendering changes based on mutations.

If you look at the markup for http_cats_fragment.hbs, we provide the id for this node:

  • reinflateContentViewNodeID: the ID of the actual content node that will be rendered based on data mutations/changes. For our case, this is the id of the parent node for the new_cat.hbs view that we inflate for the final loaded model.
  • reinflateRootViewOnNewModel: a flag which tells the view manager whether it should create a new content node if a new model is loaded. This allows us to clear data, have the old view removed, then a new view loaded after we get the new cat image. (We’ll see a different variant to this for the standard view manager later).
  • lifecycleOptions: an object which contains:
    • lifecycleInstance: the instance of the lifecycle object which will be used to listen to lifecycle changes in the hosting fragment. What this means is that view managers are also lifecycle aware, so they’ll clean up and ensure there are no side effects when the host fragment is destroyed due to route changes.

This basically sets up the standard view manager. However, there’s one more thing left. To actually get to know when model mutations are happening and update the view based on that, we need to register data hooks to the view manager. This is done by the line that invokes the view manager method


registerDataHooks();

Enter fullscreen mode Exit fullscreen mode

This method takes in two arguments, the first being the root hooks and the next being the component hooks.

Let’s break down each.

Root Hooks

Root hooks are the primary hooks called before and after a content node or view node has been attached to the DOM by the view manager, and are also called to provide the inflated view that should constitute the view node and inform you of their attachment and detachment to the DOM.

Let’s use our project as an example.

Builder Functions

We provide a root hook with the builder functions inflateRoot, onViewAttach, and onViewDetach. These methods are standard across all view managers and simply mean the following:

  • inflateRoot is called to get the content view that the view manager should attach to the DOM, inside the node with the parentNodeID. This view is supplied as a html string.
  • onViewAttach is called** after** the view manager attaches the content view is attached to the DOM. It’s viewNode parameter refers to the inflated or attached view node, from the inflateRoot method.
  • onViewDetach is called when the view manager is about to remove the content or view node from the DOM or more specifically, the parent node with the parentNodeID. It gives you some time to animate the detachment, if you’re running transitions. ##### Pre-Post Root Attach Hooks This is a very special set of data hooks that allow you to perform view operations at the grand scope or context of the parent node or view managed by the view manager. This is simply the node with the id equal to the parentNodeID.

These hooks avail four crucial methods which include:

  • onMutate – Called when data affecting the view manager’s scope is being mutated or changed.
  • onCommit – Called when data affecting the view manager’s scope has been committed to the data manager’s model
  • onError – Called when a mutation affecting data of the view manager’s scope has failed, and the user needs to be informed to retry directly
  • onCancel – Called when a mutation affecting data of the view manager’s scope has failed and is being aborted (not retried).

This set of functions are great for showing UI elements such as loading screens, error messages on fail, and retry buttons to retry a failed data mutation.

Component Hooks

Component hooks provide even finer grained reactivity towards data held by the larger view manager’s scope. They typically avail the same methods as pre-post root attach hooks, but the view node parameter refers to the specific content node showing the given piece of data.

For instance, in our example, if we wanted to say, update the value of “img” as the property changes due to a user upload or update, we could simply implement a component hook of that scope and then make the changes to the DOM child elements of our content node showing that particular data.

We’ll cover this more later. For now, let’s just understand what a view manager and hooks are.

In our project, at the inflateRoot method, we get the view provided by new_cat.hbs file which will render our cat image. We attach and remove a loading ui to the parent node during onMutate and onCancel and onCommit respectively at the prePostRootAttachHooks, because they allow DOM manipulation to the parentNode of the entire view.

Finally, we set this view manager as the view manager for data of the “MODEL_ROOT” scope in the data manager using the data manager method setViewManager(), passing “MODEL_ROOT” as our scope.

Finishing Up

At this point, our data manager is fully set. It has a network interface that allows it to retrieve data at the “MODEL_ROOT” scope from the network (so, our cat image), and has a view manager set up at the same scope to react to these mutations, so the user can see when we’re loading new data and the new data that we’ve loaded.

We put everything together by attaching listeners to our “get-cat” button and trigger data loading from the data manager.

Oats~i provides the onUIBind method in fragments in which you can bind event listeners and do other view binding work to your fragment once its view has been rendered.

Our code in onUIBind looks like:


/**
     * @type {AppMainFragment['onUIBind']}
     */
    onUIBind(isServerSide){

        //set up click listener on "meeow" button
        document.getElementById("get-cat").addEventListener("click", (e) => {

            //clear existing data
            if(this.httpCatsDataManager.hasData()){

                this.httpCatsDataManager.flushAllData();
            }
            //load new random cat
            const statusCode = this.randomCodesList[RandomNumberCharGenUtils.generateRandomInteger(0, this.randomCodesList.length)];
            //move headers code to main repo. Then, finish here
            this.httpCatsDataManager.loadData("MODEL_ROOT", null, {}, null, `/api/cats/${statusCode}`).catch((err) => {

                //do this to avoid seeing the uncaught error in promise error
                //Something cool - uncomment the line below and see the error you get at times when you click on "meeow" in quick succession
                // console.error(err);
            });
        });
    }

Enter fullscreen mode Exit fullscreen mode

We simply attach a click listener to the “get-cat” button, and use it to trigger a data load through the data manager.

We do this using the data manager method loadData() which takes several arguments. The most important ones for this project are:

  • scope: The scope of the load operation. In our case, this is the “MODEL_ROOT” scope. This value is important because, by default, it dictates which network interface will be used for the network operation (which in this case, will be the “MODEL_ROOT” scope network interface).
  • overrideLoadNewAddr: the address we’ll use for this load operation. You can always override this address in the getReqBody of our network interface for various purposes such as cache bursting or adding new parameters or queries depending on your API design.

The loadData() method will use the network to load the new data, using your network interface of the “MODEL_ROOT” scope, and update the appropriate view managers for the scope as the mutation happens and commits.

Also, if we had loaded and committed some data before, which we can know using the data manager method hasData(), we can clear this data using the flushAllData() method then load a fresh image/data.

And that’s just about it.

Movies Main Fragment

I won’t get deep into explaining what’s happening in the movies main fragment as far as data management and reactivity is concerned because the concept is exactly the same.

The only difference is, instead of a standard view manager, we use a list view manager which uses classes to query the content view nodes instead of an id, because it will load several of these (and ids in html MUST be unique).

Everything else works the same.

Sign Up and Follow for the Next Tutorial

That’s just about it for lesson 2. In the next lesson, we’ll look into view panels and their usefulness in spawning new view elements in your fragment that are either directly invoked or triggered by queries in your route.

Image description

See you then.

Support Oats~i

You can support the development of Oats~i through Patreon*** or buy me a coffee.

💖 💪 🙅 🚩
oatsi
Oats〜i

Posted on August 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related