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'constname=newState("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.
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:
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 nameuserDetails.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.
exporttypeTodo= { 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 firstimport CreateTodo from'./CreateTodo/CreateTodo'import Todo from'./Todo/Todo'//export the type todo because we will reference it in Todos.exporttypeTodo= { title:string, body:string}exportdefaultfunctionApp() {//a list of todos, in form of stateconsttodos=newState<Todo[]>([])returnLayout.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:
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'exporttypeTodo= { title:string, body:string}exportdefaultfunctionApp() {//a list of todos, in form of stateconsttodos=newState<Todo[]>([])returnLayout.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 builderLayout.Column.$builder({ each: todos,builder(todo, index) {returnTodo() } }) ] }) ] })}
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 typeimporttype { Todo } from'../app'importtype { State } from'@indivice/omega'//change the function to accept a todo and indexexportdefaultfunctionTodo(todo:Todo, index:State<number>) {returnLayout.Column({ children: [Content.InlineTextBox({ children: [//use the $text directive to convert index value to text nodeindex.$text((i) =>`#${i +1}. `),//show the todo title as recievedContent.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:
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
importtype { Todo } from'./app'exportconsttodos: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 todosimport { todos as SampleTodos } from'./todo.data'exporttypeTodo= { title:string, body:string}exportdefaultfunctionApp() {consttodos=newState<Todo[]>([])//set them to todos for nowtodos.set( SampleTodos )returnLayout.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 valuesreturnTodo( 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 the above function is pretty simple. We can simply add it to our code in CreateTodo.ts
CreateTodo.ts [ Modified ]
//importing the state typeimport { State } from'@indivice/omega'import { Layout, Input, Content } from'@indivice/omega/components'//import the todo type to use it for stateimport { Todo } from'../app'exportdefaultfunctionCreateTodo(todos:State<Todo[]>) {//un-used handler function to handle adding todosfunctionaddTodo(title, body) {todos.update( prevTodo => [ ...prevTodo, { title, body } ] ) }returnLayout.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 todosimport { todos as SampleTodos } from'./todo.data'exporttypeTodo= { title:string, body:string}exportdefaultfunctionApp() {consttodos=newState<Todo[]>([])//set them to todos for nowtodos.set( SampleTodos )returnLayout.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.tsCreateTodo(todos),Content.HorizontalRule(),Layout.Column.$builder({ each: todos,builder(todo, index) {//pass those builder valuesreturnTodo( 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.
And we also need to change the addTodo function because it will be used as onclick handler for our button:
functionaddTodo() {consttitle=inputValues.title.get()constbody=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 typeimport { State } from'@indivice/omega'import { Layout, Input, Content } from'@indivice/omega/components'//import the todo type to use it for stateimport { Todo } from'../app'exportdefaultfunctionCreateTodo(todos:State<Todo[]>) {//use a store to "store" grouped or object valuesconstinputValues=State.Store({ title:"", body:"" })//event listener to add a new todofunctionaddTodo() {consttitle=inputValues.title.get()constbody=inputValues.body.get()if ( title ==""|| body =="" ) {alert("Todo cannot be empty")return//short circuit logic }todos.update( prevTodo => [ ...prevTodo, { title, body } ] ) }returnLayout.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.
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 ]
constinputValues=State.Store({ title:"", body:""})//...code in betweenfunctionInputDataHandler(event:Event, targetState:State<string>) {event.preventDefault()targetState.set( (event.target asHTMLInputElement).value )}//...code in betweenInput.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 typeimport { State } from'@indivice/omega'import { Layout, Input, Content } from'@indivice/omega/components'//import the todo type to use it for stateimport { Todo } from'../app'exportdefaultfunctionCreateTodo(todos:State<Todo[]>) {//use a store to "store" grouped or object valuesconstinputValues=State.Store({ title:"", body:"" })//event listener to add a new todofunctionaddTodo() {consttitle=inputValues.title.get()constbody=inputValues.body.get()if ( title ==""|| body =="" ) {alert("Todo cannot be empty")return//short circuit logic }todos.update( prevTodo => [ ...prevTodo, { title, body } ] ) }functionInputDataHandler(event:Event, targetState:State<string>) {event.preventDefault()targetState.set( (event.target asHTMLInputElement).value ) }returnLayout.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 typeimport { State } from'@indivice/omega'import { Layout, Input, Content } from'@indivice/omega/components'//import the todo type to use it for stateimport { Todo } from'../app'exportdefaultfunctionCreateTodo(todos:State<Todo[]>) {//use a store to "store" grouped or object valuesconstinputValues=State.Store({ title:"", body:"" })//event listener to add a new todofunctionaddTodo() {consttitle=inputValues.title.get()constbody=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: () =>"" }) }functionInputDataHandler(event:Event, targetState:State<string>) {event.preventDefault()targetState.set( (event.target asHTMLInputElement).value ) }returnLayout.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.
functionRemoveTodo( 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 typeimporttype { Todo } from'../app'importtype { State } from'@indivice/omega'//change the function to accept a todo and index, and now accept the todo itself.exportdefaultfunctionTodo(todo:Todo, index:State<number>, todos:State<Todo[]>) {//function ( event ) to listen to, to remove the todo.functionRemoveTodo( index ) {todos.update( prev =>prev.filter( (v, i) => i != index ) ) }returnLayout.Column({ children: [Content.InlineTextBox({ children: [//use the $text directive to convert index value to text nodeindex.$text((i) =>`#${i +1}. `),//show the todo title as recievedContent.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 valueRemoveTodo( 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 todosimport { todos as SampleTodos } from'./todo.data'exporttypeTodo= { title:string, body:string}exportdefaultfunctionApp() {consttodos=newState<Todo[]>([])//set them to todos for nowtodos.set( SampleTodos )returnLayout.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.tsCreateTodo(todos),Content.HorizontalRule(),Layout.Column.$builder({ each: todos,builder(todo, index) {//pass those builder valuesreturnTodo( 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.