Adding Reactivity

Using dynamic concepts to make apps react to changes

States v/s variables

In any programming language, we store data in the memory using the concept of variables. While they are great to use, variables does not react to changes on their own.

User Interfaces, on the other hand is dynamic, and it needs to know the new data if a variable was changed.

In the world of user interfaces, "State" takes the place of variables. A state in UI can react to changes, and call it's subscribers and alert them about the update.

State in Omega

A state in omega can be created using the following syntax:

import { State } from '@indivice/omega'

const name = new State("Mayukh")

States are not exclusive to components in Omega, so they can be freely used anywhere, can be used globally or locally in a component and so on.

Operating on State

We can do various tasks that might need us to change the value of a state. We have two methods to do exactly that.

State.update( callback: ( prev ) => /*Return new value*/ )
State.set( /*new value*/ ) 

And we can get the value of the state, at any instance as:

State.get() //gets us the value

Listening to changes

We can listen to changes to a state by attaching a listener callback to it.

State.listen( ( prev, newv, batch? ) => any )

The .listen method takes a callback, and we can see the callback takes three variables, prev, newv and batch.

The prev and newv are pretty straight-forroward, because they provide both the values before and after the state was updated. The batch is an interesting argument, because it returns a map of all the changes, mapped to a state that was done in a single batch.

Batching of States

From above, it is pretty obvious that the state has listener, and dynamic components uses these listeners to listen to changes and update UI accordingly.

Now, if a component depends on multiple states, and I were to update them one after the other, i.e:

const name = new State<string>("Mayukh")
const age = new State<number>(19)

function UpdateDetails(_name: string, _age: number) {
    name.set(_name)
    age.set(_age)
}

The above will call all the listeners, once for each state update. We can actually batch those updates using the special dynamic directive $batch

Anything in omega that is in the form of $<directive-name> is a dynamic directive.

Few examples are:

  • $batch

  • $builder

  • $switch & $when ( Used together )

  • $property

And so on.

Instead of the above code, we can do something like this:

const name = new State<string>("Mayukh")
const age = new State<number>(19)

function UpdateDetails(_name: string, _age: number) {
    $batch(
        name.batch(() => _name),
        age.batch(() => _age),
    )
}

This will ensure, that after both states are updated, then the changes will be reflected.

Stores instead of batches

If you need to have states that will be updated concurrently, at any point in time, it is better to just use a Store instead of individual states.

The above scenario can be easily redone, much more efficiently using stores as follows:

const userDetails = State.Store({
    name: "Mayukh",
    age: 19
})

function UpdateDetails(_name: string, _age: number) {
    userDetails.update({
        name: () => _name,
        age: () => _age
    })
}

It is NOT mandatory to include all the data in a Store. Store can have specific updates or selective updates

Now we can get any one of the State as:

userDetails.name.get() //gets the name
userDetails.age.get() //gets the age

We can also set any one of them individually

userDetails.name.update(() => /*updated data*/)

userDetails.update({
    age: () => /* new age */
})

userDetails.name.listen(() => any) //listen to any of them

//both of the above are valid

States for Todo

Now coming back to the todo application, we need to store our todos in a State, because our todos will actually get updated dynamically and we need to reflect that change in our UI.

Let's start by defining our todo, and how it would look like as a type.

export type Todo = {
    title: string,
    body: string
}

For our purposes, a simple type is sufficient. We will now go to app.ts and add the following code to it:

app.ts [ Modified ]
import { Content, Layout } from '@indivice/omega/components'
import { State } from '@indivice/omega' //import State from omega first
import CreateTodo from './CreateTodo/CreateTodo'
import Todo from './Todo/Todo'

//export the type todo because we will reference it in Todos.
export type Todo = {
    title: string,
    body: string
}

export default function App() {

    //a list of todos, in form of state
    const todos = new State<Todo[]>([])

    return Layout.Column({
        style: {
            justifyContent: 'center',
            alignItems: 'center',
            height: '100vh'
        },
        
        children: [
            Layout.Column({
                style: { width: "300px" },
                children: [
                    Content.TextBox({
                        child: Content.Text("Todo App")
                    }),
                    
                    CreateTodo(),
                    Content.HorizontalRule(),
                    Todo()
                ]
            })
        ]
    })
}

Now we need to be able to go through the list and show each of the elements. We can use .map() method, but it is not dynamic in nature.

Building List Components

In our case, we need to be able to dynamically build UI components from a list in a given State. All layouts in Omega has a $builder directive that can build a list of components based on the state.

On the top level, to demonstrate how $builder works, we can think of the example below:

const Employee = new State([
    {
        name: "Ryan",
        salary: 16500
    },
    {
        name: "Alex",
        salary: 16500
    }
])

export function EmployeePanel() {
    return Layout.Column({
        children: [
            Content.TextBox({
                child: Content.Text("Employee Panel")
            }),
            
            Layout.Column.$builder({
                each: Employee,
                builder(employee, index) {
                    return EmployeeData({
                        sn: index,
                        ...employee
                    })
                }
            })
        ]
    })
}

It is to be noted, that the index provided by the builder method in $builder directive is of type State<number> instead of a pure number. This is because the changes in data, are adjusted by index only.

For the most part, unless the data at any index changes, using the static value provided by the builder method is a better option. If in any case we need to adjust the data at any particular index, we should get them by index.

Now using the same concept for our todos app,

app.ts [ Modified ]
import { Content, Layout } from '@indivice/omega/components'
import { State } from '@indivice/omega'
import CreateTodo from './CreateTodo/CreateTodo'
import Todo from './Todo/Todo'

export type Todo = {
    title: string,
    body: string
}

export default function App() {

    //a list of todos, in form of state
    const todos = new State<Todo[]>([])

    return Layout.Column({
        style: {
            justifyContent: 'center',
            alignItems: 'center',
            height: '100vh'
        },
        
        children: [
            Layout.Column({
                style: { width: "300px" },
                children: [
                    Content.TextBox({
                        child: Content.Text("Todo App")
                    }),
                    
                    CreateTodo(),
                    Content.HorizontalRule(),
                    
                    //create a dynamic list builder
                    Layout.Column.$builder({
                        each: todos,
                        builder(todo, index) {
                            return Todo()
                        }
                    })
                    
                ]
            })
        ]
    })
}

Now for the todo to actually show the data, we need to pass the data to the Todo component. This can be done by simply passing the todo value and index state as arguments to them. First, we need to edit our Todo.ts code to accept a todo and index and then pass them from app.ts.

Todo.ts [ Modified ]
import { Layout, Content, Input } from '@indivice/omega/components'

//import the todo and State type
import type { Todo } from '../app'
import type { State } from '@indivice/omega'

//change the function to accept a todo and index
export default function Todo(todo: Todo, index: State<number>) {
    return Layout.Column({
        children: [
            Content.InlineTextBox({
                children: [
                    //use the $text directive to convert index value to text node
                    index.$text((i) => `#${i + 1}. `),
                    //show the todo title as recieved
                    Content.Text(todo.title)
                ]
            }),
            
            Content.TextBox({
                //show the todo body as recieved
                child: Content.Text(todo.body)
            }),
            
            Input.Button({
                child: Content.Text("Remove Todo")
            })
        ]
    })
}

The State class in omega also provides built-in dynamic directives such as $text, $node and $property to directly bind the value of the said state to a TextNode, HTMLElement and HTMLAttribute respectively.

Since the index of a todo can change when we add or remove a todo, we will bind the text node to change according to the changes in index. The inside data, unless modified directly, will NOT change when any other actions are done to the array.

Moving back to app.ts to reflect the changes done to Todo.ts by passing the todo value and index:

app.ts [ Modified ]
import { Content, Layout } from '@indivice/omega/components'
import { State } from '@indivice/omega'
import CreateTodo from './CreateTodo/CreateTodo'
import Todo from './Todo/Todo'

export type Todo = {
    title: string,
    body: string
}

export default function App() {

    const todos = new State<Todo[]>([])

    return Layout.Column({
        style: {
            justifyContent: 'center',
            alignItems: 'center',
            height: '100vh'
        },
        
        children: [
            Layout.Column({
                style: { width: "300px" },
                children: [
                    Content.TextBox({
                        child: Content.Text("Todo App")
                    }),
                    
                    CreateTodo(),
                    Content.HorizontalRule(),
                    
                    Layout.Column.$builder({
                        each: todos,
                        builder(todo, index) {
                            //pass those builder values
                            return Todo(
                                todo, index
                            )
                        }
                    })
                    
                ]
            })
        ]
    })
}

Now we can test the above code, by adding few todos manually inside the array and see if they change in our UI.

Create a data source file in the src directory

src/todo.data.ts
import type { Todo } from './app'

export const todos: Todo[] = [
    {
        title: "Learn Omega",
        body: "Learning Omega to make modern applications"
    },
    {
        title: "Build Apps",
        body: "Building Applications and learning"
    }
]

Now import them to app.ts and set it to the todos state.

app.ts [ Modified ]
import { Content, Layout } from '@indivice/omega/components'
import { State } from '@indivice/omega'
import CreateTodo from './CreateTodo/CreateTodo'
import Todo from './Todo/Todo'

//import the sample todos
import { todos as SampleTodos } from './todo.data'

export type Todo = {
    title: string,
    body: string
}

export default function App() {

    const todos = new State<Todo[]>([])
    
    //set them to todos for now
    todos.set( SampleTodos )

    return Layout.Column({
        style: {
            justifyContent: 'center',
            alignItems: 'center',
            height: '100vh'
        },
        
        children: [
            Layout.Column({
                style: { width: "300px" },
                children: [
                    Content.TextBox({
                        child: Content.Text("Todo App")
                    }),
                    
                    CreateTodo(),
                    Content.HorizontalRule(),
                    
                    Layout.Column.$builder({
                        each: todos,
                        builder(todo, index) {
                            //pass those builder values
                            return Todo(
                                todo, index
                            )
                        }
                    })
                    
                ]
            })
        ]
    })
}

After this step, you can tweak the src/todo.data.ts file as you wish, to see how changes might work and how it influences the UI.

So long if you have followed it thoroughly and exactly as said above, your application should look somewhat like this:

Creating a new todo

To create a new todo, we just have to add a new entry to the todos state, which holds an array of Todo object.

Note that the traditional methods for array will not work in a stateful environment.

todos.get().push()

Is wrong and will not work as expected. This will not update the state at all, or correctly reflect the changes.

The basic difference between State.set() and State.update() is that .set() method re-sets a new value, and .update() gives you the reference to previous value, and you can either:

  • Mutate the object/array without changing the original references

  • Re-set any new value

This, will of course not work with primitives like String and numbers. And, that does not present us with any problems, because primitive comparisons are value-based, so we can identify unique elements either way.

There are declarative methods like .filter(), .map() and spread operators like [ ...prev, newValue ] all of which preserves references to previous items of array.

So it is clear how we can update our array state:

function addTodo(title, body) {
    todos.update( prevTodo => [ ...prevTodo, { title, body } ] )
}

So the above function is pretty simple. We can simply add it to our code in CreateTodo.ts

CreateTodo.ts [ Modified ]
//importing the state type
import { State } from '@indivice/omega'
import { Layout, Input, Content } from '@indivice/omega/components'
//import the todo type to use it for state
import { Todo } from '../app'

export default function CreateTodo(todos: State<Todo[]>) {

    //un-used handler function to handle adding todos
    function addTodo(title, body) {
        todos.update( prevTodo => [ ...prevTodo, { title, body } ] )
    }

    return Layout.Column({
        children: [
            Input.Text({ placeholder: "Enter todo name" }),
            Input.TextArea({ placeholder: "Enter todo body" }),
            Input.Button({
                child: Content.Text("Add Todo")
            })
        ]
    })
}

Which also means, we need to update app.ts and pass the todos property, which is the state that holds the todos.

app.ts [ Modified ]
import { Content, Layout } from '@indivice/omega/components'
import { State } from '@indivice/omega'
import CreateTodo from './CreateTodo/CreateTodo'
import Todo from './Todo/Todo'

//import the sample todos
import { todos as SampleTodos } from './todo.data'

export type Todo = {
    title: string,
    body: string
}

export default function App() {

    const todos = new State<Todo[]>([])
    
    //set them to todos for now
    todos.set( SampleTodos )

    return Layout.Column({
        style: {
            justifyContent: 'center',
            alignItems: 'center',
            height: '100vh'
        },
        
        children: [
            Layout.Column({
                style: { width: "300px" },
                children: [
                    Content.TextBox({
                        child: Content.Text("Todo App")
                    }),
                    
                    //pass the todos from the app.ts
                    CreateTodo(todos),
                    Content.HorizontalRule(),
                    
                    Layout.Column.$builder({
                        each: todos,
                        builder(todo, index) {
                            //pass those builder values
                            return Todo(
                                todo, index
                            )
                        }
                    })
                    
                ]
            })
        ]
    })
}

Now comes the part: How can we link our inputs and add a todo? To do so, let us think about the process:

  • The user types the title in the input box

  • The user types the body in the input box

  • When the button is clicked;

    • If any of the input is empty, alert the user that todo must have both title and body

    • If both inputs have text

      • Take the values and push a new todo item to the array

      • clear the inputs

So, for the button to access the data, we need to store the data somewhere. It's very obvious that we will use the State.Store to store both the todo title and todo body.

const inputValues = State.Store({
    title: "",
    body: ""
})

And we also need to change the addTodo function because it will be used as onclick handler for our button:

function addTodo() {
    const title = inputValues.title.get()
    const body = inputValues.body.get()
    
    if ( title == "" || body == "" ) {
        alert("Todo cannot be empty")
        return //short circuit logic
    }
    
    todos.update( prevTodo => [ ...prevTodo, { title, body } ] )
}

So, let's add the above code to our CreateTodo.ts component, and add the addTodo() function as event listener to click in our button.

CreateTodo.ts [ Modified ]
//importing the state type
import { State } from '@indivice/omega'
import { Layout, Input, Content } from '@indivice/omega/components'
//import the todo type to use it for state
import { Todo } from '../app'

export default function CreateTodo(todos: State<Todo[]>) {

    //use a store to "store" grouped or object values
    const inputValues = State.Store({
        title: "",
        body: ""
    })
    
    //event listener to add a new todo
    function addTodo() {
        const title = inputValues.title.get()
        const body = inputValues.body.get()
    
        if ( title == "" || body == "" ) {
            alert("Todo cannot be empty")
            return //short circuit logic
        }
    
        todos.update( prevTodo => [ ...prevTodo, { title, body } ] )
    }

    return Layout.Column({
        children: [
            Input.Text({ placeholder: "Enter todo name" }),
            Input.TextArea({ placeholder: "Enter todo body" }),
            Input.Button({
                //add the event listener on click
                onclick: addTodo,
                child: Content.Text("Add Todo")
            })
        ]
    })
}

Now, we already stated that in omega, data flows in a single direction. The "data pipeline" can either move data from the component to state, or from the state to the component. Here is the diagram that describes the above idea:

We can, in-fact do the same for a single component as well. Let us see how we can implement this idea in Omega.

function InputDataHandler(event, targetState) {
    event.preventDefault()
    targetState.set(
        event.target.value
    )
}

Let's breakdown the above function:

  • The function looks like a function to be used in event listener.

  • We will listen to each input using oninput event.

  • To ensure synchronous UI, we will prevent the default input from happening, and "re-setting" the new input value to the target state.

  • This is example of data flow from component to state via events.

So, we need the output flow: to show the user that they had inserted some data, we need to assign value property based on changes, and we have just a directive for that!

Dynamic directives are constructs in omega that are used to do reactive work based on states. We will learn more about them in the course.

Dynamic directives can be easily identified using the prefix $. For example:

$property to bind a stateful data to a property

$node to build a node based on state changes

the $property directive helps us to bind a state to a property of the UI, here it is the value property of the input.

For the most part, there are two versions of directives available in omegajs. They are:

  • Directive functions: $property() function takes a list of state on which the directive depends on, and a callback which is the derived value of the property taken form the state.

  • Directive methods: State.$property() is a directive method, because the directive does not take any dependency list, and only depends on the state it was called from as a method.

  • Use directive methods if your dynamic directive depends on a singular state

  • Use directive functions if yout dynamic directives depends on multiple states.

It is clear that our input property value only depends on either of the state value. So we will use the directive method and bind them to the input values.

CreateTodo.ts [ Brief and Modified ]
const inputValues = State.Store({
    title: "",
    body: ""
})

//...code in between

function InputDataHandler(event: Event, targetState: State<string>) {
    event.preventDefault()
    targetState.set(
        (event.target as HTMLInputElement).value
    )
}

//...code in between

Input.Text({ 
    placeholder: "Enter todo name",
    oninput(e) {
        //handle the input events ( data from UI to state )
        InputDataHandler(e, inputValues.title)
    }, 
    //bind the value ( data from state to UI )
    value: inputValues.title.$property((title) => title) 
}),

Input.TextArea({ 
    placeholder: "Enter todo body", 
    oninput(e) {
        //handle the input events ( data from UI to state )
        InputDataHandler(e, inputValues.body)
    },
    //bind the value ( data from state to UI )
    value: inputValues.body.$property(body => body) 
})

So the complete code becomes something like this ( after all the modifications )

CreateTodo.ts [ Modified ]
//importing the state type
import { State } from '@indivice/omega'
import { Layout, Input, Content } from '@indivice/omega/components'
//import the todo type to use it for state
import { Todo } from '../app'

export default function CreateTodo(todos: State<Todo[]>) {

    //use a store to "store" grouped or object values
    const inputValues = State.Store({
        title: "",
        body: ""
    })
    
    //event listener to add a new todo
    function addTodo() {
        const title = inputValues.title.get()
        const body = inputValues.body.get()
    
        if ( title == "" || body == "" ) {
            alert("Todo cannot be empty")
            return //short circuit logic
        }
    
        todos.update( prevTodo => [ ...prevTodo, { title, body } ] )
    }
    
    function InputDataHandler(event: Event, targetState: State<string>) {
        event.preventDefault()
        targetState.set(
            (event.target as HTMLInputElement).value
        )
    }

    return Layout.Column({
        children: [
            Input.Text({ 
                placeholder: "Enter todo name",
                oninput(e) {
                    //handle the input events ( data from UI to state )
                    InputDataHandler(e, inputValues.title)
                },
                //bind the value ( data from state to UI )
                value: inputValues.title.$property((title) => title) 
            }),

            Input.TextArea({ 
                placeholder: "Enter todo body",
                oninput(e) {
                    //handle the input events ( data from UI to state )
                    InputDataHandler(e, inputValues.body)
                },
                //bind the value ( data from state to UI )
                value: inputValues.body.$property(body => body) 
            }),
            
            Input.Button({
                //add the event listener on click
                onclick: addTodo,
                child: Content.Text("Add Todo")
            })
        ]
    })
}

Now for the final part, we need to clear the inputs in addTodo function so that we can make the user have better experience and not having to clear the todo every time.

CreateTodo.ts [ Modified ]
//importing the state type
import { State } from '@indivice/omega'
import { Layout, Input, Content } from '@indivice/omega/components'
//import the todo type to use it for state
import { Todo } from '../app'

export default function CreateTodo(todos: State<Todo[]>) {

    //use a store to "store" grouped or object values
    const inputValues = State.Store({
        title: "",
        body: ""
    })
    
    //event listener to add a new todo
    function addTodo() {
        const title = inputValues.title.get()
        const body = inputValues.body.get()
    
        if ( title == "" || body == "" ) {
            alert("Todo cannot be empty")
            return //short circuit logic
        }
    
        todos.update( prevTodo => [ ...prevTodo, { title, body } ] )
        //clear the inputs for ease of use.
        inputValues.update({
            title: () => "",
            body: () => ""
        })
    }
    
    function InputDataHandler(event: Event, targetState: State<string>) {
        event.preventDefault()
        targetState.set(
            (event.target as HTMLInputElement).value
        )
    }

    return Layout.Column({
        children: [
            Input.Text({ 
                placeholder: "Enter todo name",
                oninput(e) {
                    //handle the input events ( data from UI to state )
                    InputDataHandler(e, inputValues.title)
                },
                //bind the value ( data from state to UI )
                value: inputValues.title.$property((title) => title) 
            }),

            Input.TextArea({ 
                placeholder: "Enter todo body",
                oninput(e) {
                    //handle the input events ( data from UI to state )
                    InputDataHandler(e, inputValues.body)
                },
                //bind the value ( data from state to UI )
                value: inputValues.body.$property(body => body) 
            }),
            
            Input.Button({
                //add the event listener on click
                onclick: addTodo,
                child: Content.Text("Add Todo")
            })
        ]
    })
}

If you followed all the above code, you will be able to add the todos to the list easily. Though you cannot remove them yet ( Logic for that will be discussed soon. ). Here is the interactive application to test the todo so far.

Removing a todo

The delete button will help us to remove the todo from the state. We will use the simple .filter() method to "filter" out the todo we wish to delete.

Now, you will also appreciate the design pattern of omega's indexing in the iterative views -> indexes are state that holds a number, not a pure number.

This design was selected to account for changes in the index of the array. We all know how the array index are always absolute, and the items re-arrange themselves. The diagram below will help us understand it better:

In any other framework that employs the Virtual DOM have to re-call all the associated functions, rebuild their respective component trees based on the change of index, and then reconcile before the final patching of the real dom. And all of that is too much work.

By making the index a state, there is no need to re-run the component definitions when their position changes; but data remains exactly the same. So we will use this concept and add an event listener to the button, the onclick listener, and listen to click. Then remove the todo based on it.

function RemoveTodo( index ) {
    todos.update( prev => prev.filter( (v, i) => i != index ) )
}

The above is a simple declarative syntax to "filter" the previous todos, and to remove any todo with the said index. So the todo must access the todos ( we need to pass the todos state from app.ts ) and then use it.

So the final code becomes something like this:

Todo.ts [ Modified ]
import { Layout, Content, Input } from '@indivice/omega/components'

//import the todo and State type
import type { Todo } from '../app'
import type { State } from '@indivice/omega'

//change the function to accept a todo and index, and now accept the todo itself.
export default function Todo(todo: Todo, index: State<number>, todos: State<Todo[]>) {
    
    //function ( event ) to listen to, to remove the todo.
    function RemoveTodo( index ) {
        todos.update( prev => prev.filter( (v, i) => i != index ) )
    }
    
    return Layout.Column({
        children: [
            Content.InlineTextBox({
                children: [
                    //use the $text directive to convert index value to text node
                    index.$text((i) => `#${i + 1}. `),
                    //show the todo title as recieved
                    Content.Text(todo.title)
                ]
            }),
            
            Content.TextBox({
                //show the todo body as recieved
                child: Content.Text(todo.body)
            }),
            
            Input.Button({
                onclick() {
                    //remove the todo with latest index value
                    RemoveTodo( index.get() )
                },
                child: Content.Text("Remove Todo")
            })
        ]
    })
}

And app.ts will look like:

app.ts [ Modified ]
import { Content, Layout } from '@indivice/omega/components'
import { State } from '@indivice/omega'
import CreateTodo from './CreateTodo/CreateTodo'
import Todo from './Todo/Todo'

//import the sample todos
import { todos as SampleTodos } from './todo.data'

export type Todo = {
    title: string,
    body: string
}

export default function App() {

    const todos = new State<Todo[]>([])
    
    //set them to todos for now
    todos.set( SampleTodos )

    return Layout.Column({
        style: {
            justifyContent: 'center',
            alignItems: 'center',
            height: '100vh'
        },
        
        children: [
            Layout.Column({
                style: { width: "300px" },
                children: [
                    Content.TextBox({
                        child: Content.Text("Todo App")
                    }),
                    
                    //pass the todos from the app.ts
                    CreateTodo(todos),
                    Content.HorizontalRule(),
                    
                    Layout.Column.$builder({
                        each: todos,
                        builder(todo, index) {
                            //pass those builder values
                            return Todo(
                                todo, index, todos //pass the reference to the todos as well
                            )
                        }
                    })
                    
                ]
            })
        ]
    })
}

Which will give us the ability to remove the todo as well!

What's Next?

We have covered all the basic functionality of a todo app, and covered a lot of things. But a lot of things still remains! We will cover more advanced topics such as:

  • Styling using tailwindcss

  • In-depth guide to dynamic directives

  • Best practices of dynamic directives and states

For now, try to make more applications by yourself! Some ideas are: TikTakToe game, Word Censor or Word Guesser and any ideas that you may have.

Last updated