- Notifications
You must be signed in to change notification settings - Fork43
🤯 beginners tutorial building a real time counter in Phoenix 1.7.14 + LiveView 1.0 ⚡️ Learn the fundamentals from first principals so you can make something amazing! 🚀
License
dwyl/phoenix-liveview-counter-tutorial
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
Build yourfirst App usingPhoenix LiveView 🥇
andunderstand all the concepts in10 minutes orless! 🚀
Try it:livecount.fly.dev
- Phoenix LiveView Counter Tutorial
- Why? 🤷♀️
- What? 💭
- Who? 👤
- How? 💻
- Step 0: Run theFinished Counter App on your
localhost
🏃 - Step 1: Create the App 🆕
- Step 2: Create the
counter.ex
File - Step 3: Create the
live
Route inrouter.ex
- Step 4:Share State Between Clients!
- Congratulations! 🎉
- Tests! 🧪
- Bonus Level: Use a
LiveView Component
(Optional) - Moving state out of the
LiveView
- How many
people
are viewing the Counter? - More Tests!
- Step 0: Run theFinished Counter App on your
- Done! 🏁
- What'sNext?
- Feedback 💬 🙏
- Credits + Thanks! 🙌
There are several apps around the Internetthat usePhoenix LiveView
butnone includestep-by-step instructionsacomplete beginner can follow ... 😕
This is thecomplete beginner's tutorialwewish we had whenlearningLiveView
and the oneyou have been searching for! 🎉
Acomplete beginners tutorial for buildingthe most basic possiblePhoenix LiveView
Appwithno prior experience necessary.
PhoenixLiveView
allows you to buildrich interactive web appswithrealtime reactive UI (no page refresh when data updates)without writingJavaScript
!This enables buildingincredible interactive experienceswithconsiderably less code.
LiveView
pages load instantly because they are rendered on the Serverand they require far less bandwidth than a similarReact, Vue.js, Angular, etc. because only thebare minimumis loaded on the client for the page to work.
For a sneak peak of what is possible to build withLiveView
,watch@chrismccord'sLiveBeats
intro:
Phoenix.LiveView.LiveBeats.Demo.mp4
Tip: Enable thesound. It's a collaborative music listening experience. 🎶Try the
LiveBeats
Demo:livebeats.fly.dev😍 🤯 🙏
This tutorial is aimed atpeople
who havenever builtanything inPhoenix
orLiveView
.You canspeed-run it in10 minutesif you're already familiar withPhoenix
orRails
.
If you get stuck at any pointwhile following the tutorialor you have any feedback/questions,pleaseopen an issue onGitHub
!
If you don't have a lot of time or bandwidth to watch videos,this tutorial will be thefastest way to learnLiveView
.
This is the tutorial wewish we'd had when we first started usingLiveView
...
If you find it useful, please give it a star ⭐ it onGithub
so that otherpeople
will discover it.
Thanks! 🙏
Before you start working through the tutorial,you will need:
a.Elixir
installed on your computer.See:learn-elixir#installation
When you run the command:
elixir -v
At the time of writing, you should expect to see output similar to the following:
Elixir1.17.3(compiledwithErlang/OTP26)
This informs us we are usingElixir version 1.17.3
which is thelatest version at the time of writing.Some of the more advanced features of Phoenix 1.7 during compilation time require elixir1.17
although the code will work in previous versions.
b.Phoenix
installed on your computer.see:hexdocs.pm/phoenix/installation
If you run the following command in your terminal:
mix phx.new -v
You should see something similar to the following:
Phoenix installer v1.7.14
If you have an earlier version,definitely upgrade to get thelatest features!
If you have alater version ofPhoenix
,and you getstuck at any point,pleaseopen an issue on GitHub!We are here to help!
c. Basic familiarity withElixir
syntaxisrecommended butnot essential;
If you knowany programming language,you can pick it up as you go andask questionsif you get stuck!See:https://github.com/dwyl/learn-elixir
This tutorial takes you through all the stepsto build and test a counter in Phoenix LiveView.
We always"begin with the end in mind"so we recommend running thefinished appon your machinebefore writing any code.
💡 You can also try the version deployed to Fly.io:livecount.fly.dev
Before you attempt tobuild the counter,we suggest that you clone andrunthe complete app on yourlocalhost
.
That way youknow it's workingwithout much effort/time expended.
On yourlocalhost
,run the following command to clone the repoand change into the directory:
git clone https://github.com/dwyl/phoenix-liveview-counter-tutorial.gitcd phoenix-liveview-counter-tutorial
Install the dependencies by running the command:
mix setup
It will take a few seconds to download the dependenciesdepending on the speed of your internet connection;bepatient.😉
Start the Phoenix server by running the command:
mix phx.server
Now you can visitlocalhost:4000
in your web browser.
💡 Open asecond browser window (e.g. incognito mode),you will see the the counter updating in both places like magic!
You should expect to see something similar to the following:
With thefinished version of the App running on your machineand a clear picture of where we are headed, it's time tobuild it!
In your terminal,run the followingmix
commandto generate the new Phoenix app:
mix phx.new counter --no-ecto --no-mailer --no-dashboard --no-gettext
The flags after thecounter
(name of the project),tellmix phx.new
generator:
--no-ecto
- don't create a Database - we aren't storing any data--no-mailer
- this project doesn't sendemail
--no-dashboard
- we don't need a statusdashboard
--no-gettext
- no translation required
This keeps our counter as simple as possible.We can always add these featureslater if needed.
Note: Since
Phoenix
1.6
the--live
flagis no longer required when creating aLiveView
app.LiveView
is included by default in all newPhoenix
Apps.Older tutorials may still include the flag,everything ismuch easier now. 😉
When you see the following prompt:
Fetch and install dependencies? [Yn]
TypeY
followed by the[Enter]
key.That will download all the necessary dependencies.
In your terminal,go into the newly created app folder:
cd counter
And then run the followingmix
command:
mixtest
This will compile thePhoenix
appand will take some time. ⏳
You should see output similar to this:
Compiling 13 files (.ex)Generated counter app.....Finishedin 0.00 seconds (0.00s async, 0.00s sync)5 tests, 0 failuresRandomized with seed 29485
Tests all pass. ✅
This isexpected with anew
app.It's a good way to confirm everything is working.
Run the server by executing this command:
mix phx.server
Visitlocalhost:4000
in your web browser.
You should see something similar to the following:
Create a new file with the path:lib/counter_web/live/counter.ex
And add the following code to it:
defmoduleCounterWeb.CounterdouseCounterWeb,:live_viewdefmount(_params,_session,socket)do{:ok,assign(socket,:val,0)}enddefhandle_event("inc",_,socket)do{:noreply,update(socket,:val,&(&1+1))}enddefhandle_event("dec",_,socket)do{:noreply,update(socket,:val,&(&1-1))}enddefrender(assigns)do~H"""<div><h1class="text-4xl font-bold text-center"> The count is:<%=@val %></h1><pclass="text-center"><.buttonphx-click="dec">-</.button><.buttonphx-click="inc">+</.button></p></div>"""endend
The first line instructs Phoenix to use thePhoenix.LiveView
behaviour.This just means that we need to implement certain functionsfor ourLiveView
to work.
The first function ismount/3
which,as it's name suggests,mounts the modulewith the_params
,_session
andsocket
arguments:
defmount(_params,_session,socket)do{:ok,assign(socket,:val,0)}end
In our case we areignoringthe_params
and_session
arguments,hence the prepended underscore.If we were using sessions,we would need to check thesession
variable,but in this simplecounter
example, we just ignore it.
mount/3
returns atuple:{:ok, assign(socket, :val, 0)}
which uses theassign/3
function to assign the:val
key a value of0
on thesocket
.That just means the socket will now have a:val
which is initialized to0
.
The second function ishandle_event/3
which handles the incoming events received.In the case of thefirst declaration ofhandle_event("inc", _, socket)
it pattern matches the string"inc"
andincrements the counter.
defhandle_event("inc",_,socket)do{:noreply,update(socket,:val,&(&1+1))}end
handle_event/3
("inc")returns a tuple of:{:noreply, update(socket, :val, &(&1 + 1))}
where the:noreply
just means"do not send any further messages to the caller of this function".
update(socket, :val, &(&1 + 1))
as it's name suggests,willupdate the value of:val
on thesocket
to the&(&1 + 1)
is a shorthand way of writingfn val -> val + 1 end
.the&()
is the same asfn ... end
(where the...
is the function definition).If this inline anonymous function syntax is unfamiliar to you,please read:https://elixir-lang.org/crash-course.html#partials-and-function-captures-in-elixir
Thethird function isalmost identical to the one above,the key difference is that it decrements the:val
.
defhandle_event("dec",_,socket)do{:noreply,update(socket,:val,&(&1-1))}end
handle_event("dec", _, socket)
pattern matches the"dec"
Stringanddecrements the counter using the&(&1 - 1)
syntax.
In
Elixir
we can have multiplesimilar functions with thesame function namebut different matches on the argumentsor different "arity" (number of arguments).
For more detail on Functions in Elixir,see:elixirschool.com/functions/#named-functions
Finally theforth functionrender/1
receives theassigns
argument which contains the:val
stateandrenders the template using the@val
template variable.
Therender/1
function renders the template included in the function.The~H"""
syntax just means"treat this multiline string as a LiveView template"The~H
sigil
is a macro included when theuse Phoenix.LiveView
is invokedat the top of the file.
LiveView
will invoke themount/3
functionand will pass the result ofmount/3
torender/1
behind the scenes.
Each time an update happens (e.g:handle_event/3
)therender/1
function will be executedand updated data (in our case the:val
count)is sent to the client.
🏁 At the end of Step 2 you should have a file similar to:
lib/counter_web/live/counter.ex
Now that we have created ourLiveView
handler functions in Step 2,it's time to tellPhoenix
how tofind it.
Open thelib/counter_web/router.ex
file and locate the block of codethat starts withscope "/", CounterWeb do
:
scope"/",CounterWebdopipe_through:browserget"/",PageController,:indexend
Replace the lineget "/", PageController, :index
withlive("/", Counter)
.So you end up with:
scope"/",CounterWebdopipe_through:browserlive("/",Counter)end
Since we have replaced theget "/", PageController, :index
route inrouter.ex
in the previous step, the test intest/counter_web/controllers/page_controller_test.exs
will nowfail:
Compiling 6 files (.ex)Generated counter app.... 1)test GET / (CounterWeb.PageControllerTest) test/counter_web/controllers/page_controller_test.exs:4 Assertion with =~ failed code: assert html_response(conn, 200) =~"Peace of mind from prototype to production" left: "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <meta name=\"csrf-token\" content=\"EFt5PABkPz1nPg5FMAoaDSA6BCFtBCMO_4JmNTx_2vO6i3qxXjETTpal\">\n <title data-suffix=\" · Phoenix Framework\">\nLiveViewCounter\n · Phoenix Framework</title>\n <link phx-track-static rel=\"stylesheet\" href=\"/assets/app.css\">\n <script defer phx-track-static type=\"text/javascript\" src=\"/assets/app.js\">\n </script>\n </head>\n <body class=\"bg-white antialiased\">\n<div data-phx-main=\"true\" data-phx-session=\"SFMyNTY.g2gDaA\" id=\"phx-Fyi3ICCa7vPqDADE\"><header class=\"px-4 sm:px-6 lg:px-8\">\n <div class=\"flex items-center justify-between border-b border-zinc-100 py-3\">\n <div class=\"flex items-center gap-4\">\n <a href=\"/\">\n <svg viewBox=\"0 0 71 48\" class=\"h-6\" aria-hidden=\"true\">\n <path d=\"etc." fill=\"#FD4F00\"></path>\n </svg>\n </a>\n <p class=\"rounded-full bg-brand/5 px-2 text-[0.8125rem] font-medium leading-6 text-brand\">\n v1.7\n </p>\n </div>\n <div class=\"flex items-center gap-4\">\n <a href=\"https://twitter.com/elixirphoenix\" class=\"text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:text-zinc-700\">\n @elixirphoenix\n </a>\n <a href=\"https://github.com/phoenixframework/phoenix\" class=\"text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:text-zinc-700\">\n GitHub\n </a>\n <a href=\"https://hexdocs.pm/phoenix/overview.html\" class=\"rounded-lg bg-zinc-100 px-2 py-1 text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:bg-zinc-200/80 active:text-zinc-900/70\">\n Get Started <span aria-hidden=\"true\">→</span>\n </a>\n </div>\n </div>\n</header>\n<main class=\"px-4 py-20 sm:px-6 lg:px-8\">\n </p>\n \n</div>\n<div>\n<h1 class=\"text-4xl font-bold text-center\"> The count is: 0 </h1>\n\n<p class=\"text-center\">\n <button class=\"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3 text-sm font-semibold leading-6 text-white active:text-white/80 \" phx-click=\"dec\">\n -\n</button>\n <button class=\"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3 text-sm font-semibold leading-6 text-white active:text-white/80 \" phx-click=\"inc\">\n +\n</button>\n </p>\n </div>\n </div>\n</main></div>\n </body>\n</html>" right:"Peace of mind from prototype to production" stacktrace: test/counter_web/controllers/page_controller_test.exs:6: (test)Finishedin 0.1 seconds (0.06s async, 0.09s sync)5 tests, 1 failure
This just tells us that the test is looking for the string"Peace of mind from prototype to production"
in the page and did not find it.
To fix the broken test, open thetest/counter_web/controllers/page_controller_test.exs
file and locate the line:
asserthtml_response(conn,200)=~"Peace of mind from prototype to production"
Update the string from"Peace of mind from prototype to production"
to something weknow is present on the page,e.g:"The count is"
🏁 The
page_controller_test.exs.exs
file should now look like this:test/counter_web/controllers/page_controller_test.exs#L6
Confirm the tests pass again by running:
mixtest
You should see output similar to:
.....Finished in 0.08 seconds (0.03s async, 0.05s sync)5 tests, 0 failuresRandomized with seed 268653
Now that all the code for thecounter.ex
is written,run the Phoenix app with the following command:
mix phx.server
Vistlocalhost:4000
in your web browser.
You should expect to see a fully functioningLiveView
counter:
Once the initial installationand configuration ofLiveView
was complete,the creation of the actual counter wasremarkably simple.We created asingle new filelib/counter_web/live/counter.ex
that contains all the code required toinitialise, render and update the counter.Then we set thelive "/", Counter
routeto invoke theCounter
module inrouter.ex
.
In total ourcounter
App is25 lines of (relevant) code. 🤯
One important thing to note is thatthe counter only maintains statefor asingle web browser.Try opening asecond browser window (e.g: in "incognito mode")and notice how the counter only updates in one window at a time:
If we want toshare thecounter
state between multiple clients,we need to add a bit more code.
One of the biggest selling pointsof usingPhoenix
to build web appsis the built-in support forWebSockets
in the form ofPhoenix Channels
.Channels
allow us toeffortlessly sync data betweenclients and servers withminimal overhead;they are one ofElixir
(Erlang/OTP
) superpowers!
We can share thecounter
statebetween multiple clients by updating thecounter.ex
file with the following code:
defmoduleCounterWeb.CounterdouseCounterWeb,:live_view@topic"live"defmount(_session,_params,socket)doifconnected?(socket)doCounterWeb.Endpoint.subscribe(@topic)# subscribe to the channelend{:ok,assign(socket,:val,0)}enddefhandle_event("inc",_value,socket)donew_state=update(socket,:val,&(&1+1))CounterWeb.Endpoint.broadcast_from(self(),@topic,"inc",new_state.assigns){:noreply,new_state}enddefhandle_event("dec",_,socket)donew_state=update(socket,:val,&(&1-1))CounterWeb.Endpoint.broadcast_from(self(),@topic,"dec",new_state.assigns){:noreply,new_state}enddefhandle_info(msg,socket)do{:noreply,assign(socket,val:msg.payload.val)}enddefrender(assigns)do~H"""<divclass="text-center"><h1class="text-4xl font-bold text-center"> Counter:<%=@val %></h1><.buttonphx-click="dec"class="w-20 bg-red-500 hover:bg-red-600">-</.button><.buttonphx-click="inc"class="w-20 bg-green-500 hover:bg-green-600">+</.button></div>"""endend
The first change is onLine 4@topic "live"
defines a module attribute(think of it as a global constant),that lets us to reference@topic
anywhere in the file.
The second change is onLine 7where themount/3
function now creates a subscription to the@topic
when the socket is connected:
CounterWeb.Endpoint.subscribe(@topic)# subscribe to the channel topic
When the client (the browser) connects to the Phoenix server,a websocket connection is established.The interface is thesocket
andwe know that the socket is connectedwhen theconnected?/1 function returnstrue
.This is why we only subscribe to the channelwhen the socket is connected.Why do we do this?Because a websocket connection starts with an HTTP requestand HTTP is a stateless protocol.So when the client connects to the server,the server does not know if the client is already connected to the server.Once the websocket connection is established,the server knows that the client is connected,thusconnected?(socket) == true
.
Each client connected to the Appsubscribes to the@topic
so when the count is updated on any of the clients,all the other clients see the same value.
Next we update the firsthandle_event/3
function which handles the"inc"
event:
defhandle_event("inc",_msg,socket)donew_state=update(socket,:val,&(&1+1))CounterWeb.Endpoint.broadcast_from(self(),@topic,"inc",new_state.assigns){:noreply,new_state}end
Assign the result of theupdate
invocation tonew_state
so that we can use it on the next two lines.InvokingCounterWeb.Endpoint.broadcast_from
sends a message from the current processself()
on the@topic
, the key is "inc"and thevalue is thenew_state.assigns
Map.
In case you are curious (like we are),new_state
is an instance of thePhoenix.LiveView.Socket
socket:
#Phoenix.LiveView.Socket<assigns:%{flash:%{},live_view_action:nil,live_view_module:CounterWeb.Counter,val:1},changed:%{val:true},endpoint:CounterWeb.Endpoint,id:"phx-Ffq41_T8jTC_3gED",parent_pid:nil,view:CounterWeb.Counter, ...}
Thenew_state.assigns
is a Mapthat includes the keyval
where the value is1
(after we clicked on the increment button).
Thefourth update is to the"dec"
version ofhandle_event/3
defhandle_event("dec",_msg,socket)donew_state=update(socket,:val,&(&1-1))CounterWeb.Endpoint.broadcast_from(self(),@topic,"dec",new_state.assigns){:noreply,new_state}end
The only difference from the"inc"
version is the&(&1 - 1)
and "dec" in thebroadcast_from
.
Thefinal change is the implementation of thehandle_info/2
function:
defhandle_info(msg,socket)do{:noreply,assign(socket,val:msg.payload.val)}end
handle_info/2
handlesElixir
process messageswheremsg
is the received messageandsocket
is thePhoenix.Socket
.
The line{:noreply, assign(socket, val: msg.payload.val)}
just means "don't send this message to the socket again"(which would cause a recursive loop of updates).
Finally we modified theHTML
inside therender/1
functionto be a bit more visually appealing.
🏁 At the end of Step 6 the file looks like:
lib/counter_web/live/counter.ex
Now thatcounter.ex
has been updated to broadcast the count to all connected clients,let'srun the app in a few web browsers to show it inaction!
In your terminal, run:
mix phx.server
Openlocalhost:4000
in as many web browsers as you haveand test the increment/decrement buttons!
You should see the count increasing/decreasing in all browsers simultaneously!
You just built a real-time counterthat seamlessly updates all connected clientsusingPhoenix LiveView
in less than40 lines of code!
before
we get carried away celebrating that we'vefinished the counter,Let's make sure that all the functionality, however basic, is fully tested.
Open yourmix.exs
file and locate thedeps
list.Add the following line to the list:
# Track test coverage: github.com/parroty/excoveralls{:excoveralls,"~> 0.16.0",only:[:test,:dev]},
e.g:mix.exs#L58
Then, still in themix.exs
file, locate theproject
definition,and replace:
deps:deps()
With the following lines:
deps:deps(),test_coverage:[tool:ExCoveralls],preferred_cli_env:[c::test,coveralls::test,"coveralls.detail"::test,"coveralls.json"::test,"coveralls.post"::test,"coveralls.html"::test,t::test]
e.g:mix.exs#L13-L22
Finally in thealiases
section ofmix.exs
,add the following lines:
c:["coveralls.html"],s:["phx.server"],t:["test"]
Themix c
alias is the one we care about, we're going to use it immediately.The other twomix s
andmix t
are convenient shortcuts too.Hopefully you can infer what they do. 👌
With the themix.exs
file updated,run the following commands in your terminal:
mix deps.getmix c
That will download theexcoveralls
dependencyand execute the tests with coverage tracking.
You should see output similar to the following:
Randomized with seed 468341----------------COV FILE LINES RELEVANT MISSED100.0% lib/counter.ex 9 0 0 75.0% lib/counter/application.ex 34 4 1100.0% lib/counter_web.ex 111 2 0 15.9% lib/counter_web/components/core_componen 661 151 127100.0% lib/counter_web/components/layouts.ex 5 0 0100.0% lib/counter_web/controllers/error_html.e 19 1 0100.0% lib/counter_web/controllers/error_json.e 15 1 0 0.0% lib/counter_web/controllers/page_control 9 1 1100.0% lib/counter_web/controllers/page_html.ex 5 0 0100.0% lib/counter_web/endpoint.ex 46 0 0 33.3% lib/counter_web/live/counter.ex 32 12 8100.0% lib/counter_web/live/counter_component.e 17 2 0100.0% lib/counter_web/router.ex 18 2 0 80.0% lib/counter_web/telemetry.ex 69 5 1[TOTAL] 23.8%----------------Generating report...Saved to: cover/FAILED: Expected minimum coverage of 100%, got 23.8%.
This tells us that only23.8%
of the code in the project is covered by tests. 😕Let's fix that!
In the root of the project,create a file calledcoveralls.json
and add the following code to it:
{"coverage_options": {"minimum_coverage":100 },"skip_files": ["lib/counter/application.ex","lib/counter_web.ex","lib/counter_web/channels/user_socket.ex","lib/counter_web/telemetry.ex","lib/counter_web/views/error_helpers.ex","lib/counter_web/router.ex","lib/counter_web/live/page_live.ex","lib/counter_web/components/core_components.ex","lib/counter_web/controllers/error_json.ex","lib/counter_web/controllers/error_html.ex","test/" ]}
This file and theskip_files
list specifically,tellsexcoveralls
to ignore boilerplatePhoenix
fileswe cannot test.
If you re-runmix c
now you should see something similar to the following:
Randomized with seed 572431----------------COV FILE LINES RELEVANT MISSED100.0% lib/counter.ex 9 0 0100.0% lib/counter_web/components/layouts.ex 5 0 0 0.0% lib/counter_web/controllers/page_control 9 1 1100.0% lib/counter_web/controllers/page_html.ex 5 0 0100.0% lib/counter_web/endpoint.ex 46 0 0 33.3% lib/counter_web/live/counter.ex 32 12 8[TOTAL] 40.0%----------------Generating report...Saved to: cover/FAILED: Expected minimum coverage of 100%, got 40%.
This is already much better.There are only 2 files we need to focus on.Let's start by tidying up the unused files.
Given that thiscounter
App doesn't use any "controllers",we can simplyDELETE
thelib/counter_web/controllers/page_controller.ex
file.
git rm lib/counter_web/controllers/page_controller.ex
Rename thetest/counter_web/controllers/page_controller_test.exs
to:test/counter_web/live/counter_test.exs
Update the code in thetest/counter_web/live/counter_test.exs
to:
defmoduleCounterWeb.CounterTestdouseCounterWeb.ConnCaseimportPhoenix.LiveViewTesttest"connected mount",%{conn:conn}do{:ok,_view,html}=live(conn,"/")asserthtml=~"Counter: 0"endend
Re-run the tests:
mix c
You should see:
Finishedin 0.1 seconds (0.04s async, 0.07s sync)6 tests, 0 failuresRandomized with seed 603239----------------COV FILE LINES RELEVANT MISSED100.0% lib/counter.ex 9 0 0100.0% lib/counter_web/components/layouts.ex 5 0 0100.0% lib/counter_web/controllers/page_html.ex 5 0 0100.0% lib/counter_web/endpoint.ex 46 0 0 33.3% lib/counter_web/live/counter.ex 32 12 8[TOTAL] 42.9%----------------Generating report...Saved to: cover/FAILED: Expected minimum coverage of 100%, got 42.9%.
Open the coverageHTML
file:
open cover/excoveralls.html
You should see:

This shows us which functions/lines arenot being covered by ourexisting tests.
Open thetest/counter_web/live/counter_test.exs
and add the following tests:
test"Increment",%{conn:conn}do{:ok,view,_html}=live(conn,"/")assertrender_click(view,:inc)=~"Counter: 1"endtest"Decrement",%{conn:conn}do{:ok,view,_html}=live(conn,"/")assertrender_click(view,:dec)=~"Counter: -1"endtest"handle_info/2 broadcast message",%{conn:conn}do{:ok,view,_html}=live(conn,"/"){:ok,view2,_html}=live(conn,"/")assertrender_click(view,:inc)=~"Counter: 1"assertrender_click(view2,:inc)=~"Counter: 2"end
Once you've saved the file,re-run the tests:mix c
You should see:
........Finishedin 0.1 seconds (0.03s async, 0.09s sync)8 tests, 0 failuresRandomized with seed 552859----------------COV FILE LINES RELEVANT MISSED100.0% lib/counter.ex 9 0 0100.0% lib/counter_web/components/layouts.ex 5 0 0100.0% lib/counter_web/controllers/page_html.ex 5 0 0100.0% lib/counter_web/endpoint.ex 46 0 0100.0% lib/counter_web/live/counter.ex 32 12 0[TOTAL] 100.0%----------------
Done. ✅
At present therender/1
function incounter.ex
has an inline template:
defrender(assigns)do~H"""<divclass="text-center"><h1class="text-4xl font-bold text-center"> Counter:<%=@val %></h1><.buttonphx-click="dec"class="text-6xl pb-2 w-20 bg-red-500 hover:bg-red-600">-</.button><.buttonphx-click="inc"class="text-6xl pb-2 w-20 bg-green-500 hover:bg-green-600">+</.button></div>"""
This isfine when the template issmall like in thiscounter
,but in a bigger Applike ourMPV
it's a good idea tosplit the template into aseparate fileto make it easier to read and maintain.
This is wherePhoenix.LiveComponent
comes to the rescue!LiveComponents
are a mechanismto compartmentalize state, markup,and events inLiveView
.
Create a new file with the path:lib/counter_web/live/counter_component.ex
And type (or paste) the following code in it:
defmoduleCounterComponentdousePhoenix.LiveComponent# Avoid duplicating Tailwind classes and show hot to inline a function call:defpbtn(color)do"text-6xl pb-2 w-20 rounded-lg bg-#{color}-500 hover:bg-#{color}-600"enddefrender(assigns)do~H"""<divclass="text-center"><h1class="text-4xl font-bold text-center"> Counter:<%=@val %></h1><buttonphx-click="dec"class={btn("red")}> -</button><buttonphx-click="inc"class={btn("green")}> +</button></div>"""endend
Then back in thecounter.ex
file,update therender/1
function to:
defrender(assigns)do~H"""<.live_componentmodule={CounterComponent}id="counter"val={@val}/>"""end
🏁 Your
counter_component.ex
should look like this:lib/counter_web/live/counter_component.ex
The component has a similarrender/1
function to what was incounter.ex
.That's the point; we just want it in a separate filefor maintainability.
Re-run thecounter
Appusingmix phx.server
and confirm everything still works:
The tests all still pass and we have 100% coverage:
........Finishedin 0.1 seconds (0.03s async, 0.09s sync)8 tests, 0 failuresRandomized with seed 470293----------------COV FILE LINES RELEVANT MISSED100.0% lib/counter.ex 9 0 0100.0% lib/counter_web/components/layouts.ex 5 0 0100.0% lib/counter_web/controllers/page_html.ex 5 0 0100.0% lib/counter_web/endpoint.ex 46 0 0100.0% lib/counter_web/live/counter.ex 32 12 0100.0% lib/counter_web/live/counter_component.e 17 2 0[TOTAL] 100.0%----------------
With this implementation you may have noticed that when we open a new browserwindow the count is always zero. As soon as we click plus or minus it adjustsand all the views get back in line. This is because the state is replicatedacross LiveView instances and coordinated via pub-sub. If the state was bigand complicated this would get wasteful in resources and hard to manage.
Generally it is good practiceto identifyshared stateand to manage that ina single location.
TheElixir
way of managing state is theGenServer
,usingPubSub
to updatetheLiveView
about changes.This allows theLiveViews
to focus on specific state,separating concerns;making the application both more efficient(hopefully) and easier to reason about and debug.
Create a file with the path:lib/counter_web/live/counter_state.ex
and add the following:
defmoduleCounter.CountdouseGenServeraliasPhoenix.PubSub@name:count_server@start_value0# External API (runs in client process)deftopicdo"count"enddefstart_link(_opts)doGenServer.start_link(__MODULE__,@start_value,name:@name)enddefincr()doGenServer.call@name,:increnddefdecr()doGenServer.call@name,:decrenddefcurrent()doGenServer.call@name,:currentenddefinit(start_count)do{:ok,start_count}end# Implementation (Runs in GenServer process)defhandle_call(:current,_from,count)do{:reply,count,count}enddefhandle_call(:incr,_from,count)domake_change(count,+1)enddefhandle_call(:decr,_from,count)domake_change(count,-1)enddefpmake_change(count,change)donew_count=count+changePubSub.broadcast(Counter.PubSub,topic(),{:count,new_count}){:reply,new_count,new_count}endend
TheGenServer
runs in its own process.Other parts of the application invokethe API in their own process, these calls are forwarded to thehandle_call
functions in theGenServer
process where they are processed serially.
We have also moved thePubSub
publication here as well.
We are also going to need to tell the Application that it now has some businesslogic; we do this in thestart/2
function in thelib/counter/application.ex
file.
def start(_type, _args) do children = [ # Start the App State+ Counter.Count, # Start the Telemetry supervisor CounterWeb.Telemetry, # Start the PubSub system {Phoenix.PubSub, name: Counter.PubSub}, # Start the Endpoint (http/https) CounterWeb.Endpoint # Start a worker by calling: Counter.Worker.start_link(arg) # {Counter.Worker, arg} ] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Counter.Supervisor] Supervisor.start_link(children, opts) end...
Finally, we need make some changes to theLiveView
itself,it now has less to do!
defmoduleCounterWeb.CounterdouseCounterWeb,:live_viewaliasCounter.CountaliasPhoenix.PubSub@topicCount.topicdefmount(_params,_session,socket)doifconnected?(socket)doPubSub.subscribe(Counter.PubSub,@topic)end{:ok,assign(socket,val:Count.current())}enddefhandle_event("inc",_,socket)do{:noreply,assign(socket,:val,Count.incr())}enddefhandle_event("dec",_,socket)do{:noreply,assign(socket,:val,Count.decr())}enddefhandle_info({:count,count},socket)do{:noreply,assign(socket,val:count)}enddefrender(assigns)do~H"""<div><h1>The count is:<%=@val %></h1><.buttonphx-click="dec">-</.button><.buttonphx-click="inc">+</.button></div>"""endend
The initial state is retrieved from theshared ApplicationGenServer
processand the updates are being forwarded therevia its API.Finally, theGenServer
to all the activeLiveView
clients.
Given that thecounter.ex
is now using theGenServer
State,two of the tests now fail because the count is not correct.
mix tGenerated counter app..... 1)test connected mount (CounterWeb.CounterTest) test/counter_web/live/counter_test.exs:6 Assertion with =~ failed code: assert html =~"Counter: 0" left: "<html lang=\"en\" class=\"[scrollbar-gutter:stable]\"><head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><meta name=\"csrf-token\" content=\"A2QQMHc0OgsbDl0mUCZdGDoHWhUtMC4CDUIv9XHhtx2p6_iLerkvIbbk\"/><title data-suffix=\" · Phoenix Framework\">\nCounter\n · Phoenix Framework</title><link phx-track-static=\"phx-track-static\" rel=\"stylesheet\" href=\"/assets/app.css\"/><script defer=\"defer\" phx-track-static=\"phx-track-static\" type=\"text/javascript\" src=\"/assets/app.js\">\n </script></head><body class=\"bg-white antialiased\"><div data-phx-main=\"data-phx-main\" data-phx-session=\"SFMyNTY.g2gDaAJhBXQAAAAIdwJpZG0AAAAUcGh4LUYzWm01LWgycVNXZW5RREJ3B3Nlc3Npb250AAAAAHcKcGFyZW50X3BpZHcDbmlsdwZyb3V0ZXJ3GEVsaXhpci5Db3VudGVyV2ViLlJvdXRlcncEdmlld3cZRWxpeGlyLkNvdW50ZXJXZWIuQ291bnRlcncIcm9vdF9waWR3A25pbHcJcm9vdF92aWV3dxlFbGl4aXIuQ291bnRlcldlYi5Db3VudGVydwxsaXZlX3Nlc3Npb25oAncHZGVmYXVsdG4IAPP26BwRWHYXbgYA2w20ookBYgABUYA.Zae9BLTboLn6SPPe0qmktsfuru8HX2W4CALIBZNpcqE\" data-phx-static=\"SFMyNTY.g2gDaAJhBXQAAAADdwJpZG0AAAAUcGh4LUYzWm01LWgycVNXZW5RREJ3BWZsYXNodAAAAAB3CmFzc2lnbl9uZXdqbgYA2w20ookBYgABUYA.uooN8p97RRE1JN4tmkVNqC9islv-Np5B8wrewhwLnKc\" id=\"phx-F3Zm5-h2qSWenQDB\"><header class=\"px-4 sm:px-6 lg:px-8\"><div class=\"flex items-center justify-between border-b border-zinc-100 py-3 text-sm\"><div class=\"flex items-center gap-4\"><a href=\"/\"><img src=\"/images/logo.svg\" width=\"36\"/></a><p class=\"bg-brand/5 text-brand rounded-full px-2 font-medium leading-6\">\n v1.7.7\n </p></div><div class=\"flex items-center gap-4 font-semibold leading-6 text-zinc-900\"><a href=\"https://github.com/dwyl/phoenix-liveview-counter-tutorial\" class=\"hover:text-zinc-700\">\n GitHub\n </a><a href=\"https://github.com/dwyl/phoenix-liveview-counter-tutorial#how-\" class=\"rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80\">\n Get Started ..." right:"Counter: 0" stacktrace: test/counter_web/live/counter_test.exs:8: (test) 2)test Increment (CounterWeb.CounterTest) test/counter_web/live/counter_test.exs:11 Assertion with =~ failed code: assert render_click(view, :inc) =~"Counter: 1" left:"<header class=\"px-4 sm:px-6 lg:px-8\"><div class=\"flex items-center justify-between border-b border-zinc-100 py-3 text-sm\"><div class=\"flex items-center gap-4\"><a href=\"/\"><img src=\"/images/logo.svg\" width=\"36\"/></a><p class=\"bg-brand/5 text-brand rounded-full px-2 font-medium leading-6\">\n v1.7.7\n </p></div><div class=\"flex items-center gap-4 font-semibold leading-6 text-zinc-900\"><a href=\"https://github.com/dwyl/phoenix-liveview-counter-tutorial\" class=\"hover:text-zinc-700\">\n GitHub\n </a><a href=\"https://github.com/dwyl/phoenix-liveview-counter-tutorial#how-\" class=\"rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80\">\n etc..." right:"Counter: 1" stacktrace: test/counter_web/live/counter_test.exs:13: (test).Finishedin 0.1 seconds (0.02s async, 0.09s sync)8 tests, 2 failures
The test is expecting theinitial state to be0
(zero) each time.But if we are storing thecount
in theGenServer
,it will not be0
.
We can easily update the tests to check the statebefore incrementing/decrementing it.Open thetest/counter_web/live/counter_test.exs
file and replace the contents with the following:
defmoduleCounterWeb.CounterTestdouseCounterWeb.ConnCaseimportPhoenix.LiveViewTesttest"Increment",%{conn:conn}do{:ok,view,html}=live(conn,"/")current=Counter.Count.current()asserthtml=~"Counter:#{current}"assertrender_click(view,:inc)=~"Counter:#{current+1}"endtest"Decrement",%{conn:conn}do{:ok,view,html}=live(conn,"/")current=Counter.Count.current()asserthtml=~"Counter:#{current}"assertrender_click(view,:dec)=~"Counter:#{current-1}"endtest"handle_info/2 Count Update",%{conn:conn}do{:ok,view,disconnected_html}=live(conn,"/")current=Counter.Count.current()assertdisconnected_html=~"Counter:#{current}"assertrender(view)=~"Counter:#{current}"send(view.pid,{:count,2})assertrender(view)=~"Counter: 2"endend
Re-run the testsmix c
and watch them pass with 100% coverage:
.......Finishedin 0.1 seconds (0.04s async, 0.09s sync)7 tests, 0 failuresRandomized with seed 614997----------------COV FILE LINES RELEVANT MISSED100.0% lib/counter.ex 9 0 0100.0% lib/counter_web/components/layouts.ex 5 0 0100.0% lib/counter_web/controllers/page_html.ex 5 0 0100.0% lib/counter_web/endpoint.ex 46 0 0100.0% lib/counter_web/live/counter.ex 31 7 0100.0% lib/counter_web/live/counter_component.e 17 2 0100.0% lib/counter_web/live/counter_state.ex 53 12 0[TOTAL] 100.0%----------------
Phoenix
has a very cool feature calledPresence
to track how manypeople
(connected clients) are using our system.It does a lot more than count clients,but this is a counting app so ...
First of all we need to tell theApplication
we are going to usePresence
.For this we need to create alib/counter/presence.ex
file and add the following lines of code:
defmoduleCounter.PresencedousePhoenix.Presence,otp_app::counter,pubsub_server:Counter.PubSubend
and tell the application about it in thelib/counter/application.ex
file (add it just below the PubSub config):
def start(_type, _args) do children = [ # Start the App State Counter.Count, # Start the Telemetry supervisor CounterWeb.Telemetry, # Start the PubSub system {Phoenix.PubSub, name: Counter.PubSub},+ Counter.Presence, # Start the Endpoint (http/https) CounterWeb.Endpoint # Start a worker by calling: Counter.Worker.start_link(arg) # {Counter.Worker, arg} ] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Counter.Supervisor] Supervisor.start_link(children, opts) end
The application doesn't need to know any more about the user count (it might,but not here) so the rest of the code goes intolib/counter_web/live/counter.ex
.
- We subscribe to and participate in the Presence system (we do that in
mount
) - We handle Presence updates and use the current count, adding joiners andsubtracting leavers to calculate the current numbers 'present'. We do thatin a pattern matched
handle_info
.Notice that since we populate the socket's state in themount/3
callback,and compute the Presence there, we need to remove the connected clientfrom the joins in thehandle_info
callback.We useMap.pop/3
to remove the client from the joins (note:Math.pop/3
returnsa default map we parse in ifkey
is not present in the map).This works because the client is identified by the socket'sid
and Presenceprocess returns a map whose key value is thesocket.id
. - We publish the additional data to the client in
render
defmodule CounterWeb.Counter do use CounterWeb, :live_view alias Counter.Count alias Phoenix.PubSub+ alias Counter.Presence @topic Count.topic+ @presence_topic "presence" def mount(_params, _session, socket) do+ initial_present = if connected?(socket) do PubSub.subscribe(Counter.PubSub, @topic)+ Presence.track(self(), @presence_topic, socket.id, %{})+ CounterWeb.Endpoint.subscribe(@presence_topic)++ Presence.list(@presence_topic)+ |> map_size+ else+ 0+ end+ {:ok, assign(socket, val: Count.current(), present: initial_present) }- {:ok, assign(socket, val: Count.current()) } end def handle_event("inc", _, socket) do {:noreply, assign(socket, :val, Count.incr())} end def handle_event("dec", _, socket) do {:noreply, assign(socket, :val, Count.decr())} end def handle_info({:count, count}, socket) do {:noreply, assign(socket, val: count)} end+ def handle_info(+ %{event: "presence_diff", payload: %{joins: joins, leaves: leaves}},+ %{assigns: %{present: present}} = socket+ ) do+ {_, joins} = Map.pop(joins, socket.id, %{})+ new_present = present + map_size(joins) - map_size(leaves)++ {:noreply, assign(socket, :present, new_present)}+ end def render(assigns) do ~H""" <.live_component module={CounterComponent} val={@val} />+ <.live_component module={PresenceComponent} present={@present} /> """ endend
You will have noticed that last addition in therender/1
function invokes aPresenceComponent
.It doesn't exist yet, let's create it now!
Create a file with the path:lib/counter_web/live/presence_component.ex
and add the following code to it:
defmodulePresenceComponentdousePhoenix.LiveComponentdefrender(assigns)do~H"""<h1class="text-center pt-2 text-xl">Connected Clients:<%=@present %></h1>"""endend
Now, as you open and close your incognito windows tolocalhost:4000
,you will get a count of how many are running.
Once you have implemented the solution,you need to make sure that the new code is tested.Open thetest/counter_web/live/counter_test.exs
fileand add the following tests:
test"handle_info/2 Presence Update - Joiner",%{conn:conn}do{:ok,view,html}=live(conn,"/")asserthtml=~"Connected Clients: 1"send(view.pid,%{event:"presence_diff",payload:%{joins:%{"phx-Fhb_dqdqsOCzKQAl"=>%{metas:[%{phx_ref:"Fhb_dqdrwlCmfABl"}]}},leaves:%{}}})assertrender(view)=~"Connected Clients: 2"endtest"handle_info/2 Presence Update - Leaver",%{conn:conn}do{:ok,view,html}=live(conn,"/")asserthtml=~"Connected Clients: 1"send(view.pid,%{event:"presence_diff",payload:%{joins:%{},leaves:%{"phx-Fhb_dqdqsOCzKQAl"=>%{metas:[%{phx_ref:"Fhb_dqdrwlCmfABl"}]}}}})assertrender(view)=~"Connected Clients: 0"end
With those tests in place, re-running the tests with coveragemix c
,you should see the following:
.........Finishedin 0.1 seconds (0.04s async, 0.1s sync)9 tests, 0 failuresRandomized with seed 958121----------------COV FILE LINES RELEVANT MISSED100.0% lib/counter.ex 9 0 0100.0% lib/counter/presence.ex 5 0 0100.0% lib/counter_web/components/layouts.ex 5 0 0100.0% lib/counter_web/controllers/page_html.ex 5 0 0100.0% lib/counter_web/endpoint.ex 46 0 0100.0% lib/counter_web/live/counter.ex 51 13 0100.0% lib/counter_web/live/counter_component.e 17 2 0100.0% lib/counter_web/live/counter_state.ex 53 12 0100.0% lib/counter_web/live/presence_component. 9 2 0[TOTAL] 100.0%----------------
That's it for this tutorial.
We hope you enjoyed learning with us!
If you found this useful,please ⭐️ andshare theGitHub
reposo we know you like it!
If you've enjoyed this basiccounter
tutorialand want something a bit more advanced,checkout ourLiveView
Chat Tutorial:github.com/dwyl/phoenix-liveview-chat-example 💬
Then if you want a more advanced "real world" Appthat usesLiveView
extensivelyincludingAuthentication
and some client-sideJS
,checkout ourMVP
App/dwyl/mvp📱⏳✅ ❤️
Several people in theElixir
/Phoenix
communityhave found this tutorial helpful when starting to useLiveView
,e.g: Kurt Mackey@mrkurt
twitter.com/mrkurt/status/1362940036795219973
He deployed the counter app to a 17 region cluster using fly.io:https://liveview-counter.fly.dev
Code:https://github.com/fly-apps/phoenix-liveview-cluster/blob/master/lib/counter_web/live/counter.ex
Your feedback is always very muchwelcome! 🙏
Credit for inspiring this tutorial goes to Dennis Beatty@dnsbtyfor his superb post:https://dennisbeatty.com/2019/03/19/how-to-create-a-counter-with-phoenix-live-view.htmland corresponding video:youtu.be/2bipVjOcvdI
We recommendeveryone learningElixir
subscribe to his YouTube channel and watchall his videosas they are asuperb resource!
The 3 key differencesbetween this tutorial and Dennis' original post are:
- Complete code commit (snapshot) at the end of each section(not just inline snippets of code).
We feel that having thecomplete codespeeds up learning significantly, especially if (when) we getstuck. - Latest
Phoenix
,Elixir
andLiveView
versions.Many updates have been made toLiveView
setup since Dennis published his video,these are reflected in our tutorial which uses thelatest release. - Broadcast updates to all connected clients.So when the counter is incremented/decremented in one client,all others see the update.This is the true power and "WOW Moment" of
LiveView
!
If you are new to LiveView (and have the bandwidth),we recommend watchingJames@knowthen Moore'sintro to LiveView where he explains the concepts:youtu.be/U_Pe8Ru06fM
Watching the video isnot required;you will be able to follow the tutorial without it.
Chris McCord (creator of Phoenix and LiveView) hasgithub.com/chrismccord/phoenix_live_view_exampleIt's a great collection of examples for people who already understand LiveView.However we feel that it is not very beginner-friendly(at the time of writing).Only the default "start your Phoenix server" instructions are included,and thedependencies have divergedso the app does notcompile/run for some people.We understand/love that Chris is focussedbuildingPhoenix and LiveView so we decided to fill in the gapsand write thisbeginner-focussed tutorial.
If you haven't watched Chris' Keynote from ElixirConf EU 2019,wehighly recommend watching it:youtu.be/8xJzHq8ru0M
Also read the original announcement for LiveView to understand the hype!
:https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript
Sophie DeBenedetto's ElixirConf 2019 talk "Beyond LiveView:Building Real-Time features with Phoenix LiveView, PubSub,Presence and Channels (Hooks) is worth watching:youtu.be/AbNAuOQ8wBE
Related blog post:https://elixirschool.com/blog/live-view-live-component/
- Real-Time Form Validation with Phoenix LiveView:https://blog.appsignal.com/2021/09/28/real-time-form-validations-with-phoenix-liveview.html
- Optimizing User Experience with LiveView:https://dockyard.com/blog/2020/12/21/optimizing-user-experience-with-liveview
TDD
withLiveView
:https://youtu.be/KfW3l3qJPH8
About
🤯 beginners tutorial building a real time counter in Phoenix 1.7.14 + LiveView 1.0 ⚡️ Learn the fundamentals from first principals so you can make something amazing! 🚀
Topics
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Uh oh!
There was an error while loading.Please reload this page.