Phoenix Liveview is a new library that works with Phoenix and provides real-time user experiences with server-rendered HTML. The library documentation itself says LiveView provides rich, real-time user experiences with server-rendered HTML
. In this blog, we are going to harness the power and easiness of Liveview
and GenServer
.
What we are going to build?
We are going to build a simple notetaking app with basic CRUD functionality. Its UI will be similar to the Notes
app in Mac OS. I got this inspiration from the Reactjs crash course I did on Udemy. Styling is very much similar to the app in that course. Below screen recording is the entire flow of the project, this will give an idea of the project.
Why GenServer?
The GenServer is a process that has its own state. We are going to use this state to manage our application state instead of using the database. This is obviously not an ideal scenario to use GenServer, but the purpose of this blog is to introduce LiveView and GenServer usage.
Action plan!!!
This blog is 2 part series.
- Part 1: We will be generating the application and will add API Layer and Process Layer i.e Genserver (This is Part 1)
- Part 2: We will build UI using Liveview and integrate the different layers we created in Part 1
Let's start building 👷
- First of all, our system should have Elixir installed. We can confirm this by running
Elixir -v
. - Once confirmed we need to create/generate Phoenix LiveView application.
- This can be done by running
mix phx.new note_app --no-ecto --live
- if you get this error
Then run** (Mix) The task "phx.new" could not be found Note no mix.exs was found in the current directory
mix archive.install hex phx_new
- Then navigate to project directory
cd note_app
and runmix phx.server
. Once, the project starts running navigate to localhost:4000 to see the welcome phoenix screen.
Creating GenServer!!
- As promised in the introduction of this blog, we are going to create a Process Layer by using GenServer in it.
- Create a file
note_server.ex
insidelib/note_app/notes
. - Inside the
note_server.ex
file add the following code.defmodule NoteApp.Notes.NoteServer do use GenServer end
use GenServer
is a macro that adds GenServer behavior in this file.- The documentation says
GenServer behavior abstracts the common client-server interaction. Developers are only required to implement the callbacks and functionality they are interested in.
- We are going to implement these callbacks and functionality. That will be our four basic CRUD operations on the note.
def all_notes() # To list all notes def create_note(note) # To create note and will take struct as a parameter def get_note(id) # To get particular note def delete_note(id) # To delete particular note def update_note(note) #To update note and will take struct as a parameter
Inititalizing GenServer
- The GenServer has the function
start_link
which is used to start the process. We are going to add our ownstart_link
function - This function will start the process by calling the
GenServer.start_link()
function which takes some argument. - We will not be passing any parameter so we will ignore the
args
parameter.def start_link(_args) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end
Genserver#start_link
function takes 3 parameters.- module name here in this case
__MODULE__
gives the name of the module i.eNoteApp.Notes.NoteServer
- Empty list
[]
since initially our note will have nonotes
hence empty list. - It takes a list of options, we are passing
name
.
- module name here in this case
- As soon as the
start_link
function is called it callsinit
function as a part of the callback call. Let's add it.def init(notes) do {:ok, notes} end
- It has one parameter it is nothing but the initial empty list we created. This callback return
:ok
tuple with this notes list. - We can test this by opening iex shell by running
iex -S mix
command.{:ok, pid} = NoteApp.Notes.NoteServer.start_link(nil)
- This is going to return an
ok
tuple with somepid
value. Something like this{:ok, #PID<0.378.0>}
with a different number. - This PID is nothing but
Process Identifier
, which confirms that a separate process has been started by GenServer which will have its own state. - By pattern matching, we have assigned this
process identifier
topid
variable which we'll use later - If you may remember we initialize our GenServer with an empty list. We can confirm this by running
:sys.get_state(pid)
and passingpid
value to theget_state()
function. - This will return an empty list i.e
[]
. You can use this function time to time in shell to check the state of the process.
Implementing CRUD functions
- We have already discussed we are going to have 5 CRUD functions. Now, we are going to implement that.
- GenServer has two types of functions client-facing functions and server functions.
- The above CRUD functions we mentioned will be client functions which will be called from the UI of our apps.
- These functions will eventually call server functions.
- Both client and server functions are usually defined in the same fine depending upon the complexity of the project.
- We can call the
server
by sending two types of messages. Thecall
messages expect a reply from the server andcast
messages do not reply. - Let's see one example of each.
- First method that we are going to implement is
all_notes
. As the name suggests, with
all_notes
we are expecting some answer from the server. So, we will usecall
.defmodule NoteApp.Notes.NoteServer do # alias NoteApp.Notes.Note use GenServer # Client def start_link(_args) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def all_notes(pid) do GenServer.call(pid, :all_notes) end
- Now the line
GenServer.call
expects a callback where we will be writing our logic to fetch data from the state.def handle_call(:all_notes, _from, notes) do {:reply, notes, notes} end
- As we know,
call
returns the current state of the process i.e list of notes. Hence, the tuple is:reply
. - We can test it in the console by calling
NoteServer.all_notes(pid)
. It will return the list, for now, which is empty.iex(3)> NoteServer.all_notes(pid) #=> []
Similarly, we can use
cast
which does not return anything and is asynchronous. We will create thecreate_note
function which takesmap
as a parameter and createsnote
asynchronously.#Client def create_note(pid, note) do GenServer.cast(__MODULE__, {:create_note, note}) end #Server def handle_cast({:create_note, note}, notes) do updated_note = add_id(notes, note) updates_notes = [updated_note | notes] {:noreply, updates_notes} end
- In the callback
:create_note
is the atom to identify the callback withnote
as the parameter that we got in the client function and the second parameternotes
is nothing but the current state of the process i.e list of notes. - Since, it is
cast
hence we return nothing with:noreply
tuple. This is how we implement
call
andcast
. Similarly, we are going to implement the rest of the functions.defmodule NoteApp.Notes.NoteServer do # alias NoteApp.Notes.Note use GenServer # Client def start_link(_args) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def all_notes(pid) do GenServer.call(__MODULE__, :all_notes) end def create_note(pid, note) do GenServer.cast(__MODULE__, {:create_note, note}) end def get_note(pid, id) do GenServer.call(__MODULE__, {:get_note, id}) end def delete_note(pid, id) do GenServer.cast(__MODULE__, {:delete_note, id}) end def update_note(pid, note) do GenServer.cast(__MODULE__, {:update_note, note}) end # Server @impl true def init(notes) do {:ok, notes} end @impl true def handle_cast({:create_note, note}, notes) do updated_note = add_id(notes, note) updates_notes = [updated_note | notes] {:noreply, updates_notes} end @impl true def handle_cast({:delete_note, id}, notes) do updated_notes = notes |> Enum.reject(fn note -> Map.get(note, :id) == id end) {:noreply, updated_notes} end @impl true def handle_cast({:update_note, note}, notes) do updated_notes = update_in(notes, [Access.filter(& &1.id == note.id)], fn _ -> note end) {:noreply, updated_notes} end @impl true def handle_call({:get_note, id}, _from, notes) do found_note = notes |> Enum.filter(fn note -> Map.get(note, :id) == id end) |> List.first {:reply, found_note, notes} end @impl true def handle_call(:all_notes, _from, notes) do {:reply, notes, notes} end defp add_id(notes, note) do id = (notes |> Kernel.length) + 1 %{note | id: id} end end
Managing GenServer by Supervisor!!
- So far, we have added the process layer and tested the API in the
iex
shell. - In real life app we don't want to manually start the GenServer and keep track of the PID and check process health and keep track of whether it is working fine or crashed.
- We will give this responsibility to Supervisor.
- We are going to start the GenServer process as soon as our application boots up.
- For this, we are going to make some changes in code and make entry of our
NoteServer
module in theapplication.ex
file. - Open
application.ex
file and in functiondef start
add{NoteApp.Notes.NoteServer, nil}
after the lineNoteAppWeb.Endpoint,
def start(_type, _args) do ....... ....... NoteAppWeb.Endpoint, {NoteApp.Notes.NoteServer, nil} end
- You will notice
{NoteApp.Notes.NoteServer, nil}
is a tuple where the first element is our GenServer module name and the second parameter isnil
.nil
is the value we need to pass to thestart_link
function, we don't need any hence it isnil
. - We also need to make some changes in the client functions parameter of the GenServer. i.e
all_notes(), create_note(note), get_note(id), delete_note(id),update_note(note)
. - In earlier code, we passed
pid
as our first parameter to these functions but now we can remove that code because our process is identified by module name (Referstart_link
function where__MODULE__
is one of the parameters.
Testing
- Run the server by running
iex -S mix phx.server
in the terminal. This runs the server as well as the iex Shell. - Now try initializing the GenServer by running
{:ok, pid} = NoteApp.Notes.NoteServer.start_link(nil)
. You will get an error in the console.** (MatchError) no match of right hand side value: {:error, {:already_started, #PID<0.502.0>}}
- We are not sure is our GenServer really running. What is the pid of the process? What would be its current state right now? Let's find out.
- Lets run
pid = Process.whereis(NoteApp.Notes.NoteServer)
in the terminal. It is going to return thepid
of our GenServer which was initalized as soon as app starts running. - We can use this
pid
to get the latest state of our application in the shell by running:sys.get_state(pid)
. It will return an empty list. Since we haven't played with the app yet. - You can play with different functions in the API layer and check the state at every step to test the application.
This is the end of this blog and part 1. Here we have just created the API layer of the code and taken care of our GenServer which is going to handle the state of our project. In next part we will integrate this API with LiveView and will also create the UI and complete the app.
I hope you like this blog.If you have any questions then please comment below.