Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

🤯 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

NotificationsYou must be signed in to change notification settings

dwyl/phoenix-liveview-counter-tutorial

Repository files navigation

GitHub Workflow Statuscodecov.ioHex.pmcontributions welcomeHitCount

Build yourfirst App usingPhoenix LiveView 🥇
andunderstand all the concepts in10 minutes orless! 🚀
Try it:livecount.fly.dev



Why? 🤷‍♀️

There are several apps around the Internetthat usePhoenix LiveViewbutnone includestep-by-step instructionsacomplete beginner can follow ... 😕
This is thecomplete beginner's tutorialwewish we had whenlearningLiveViewand the oneyou have been searching for! 🎉

What? 💭

Acomplete beginners tutorial for buildingthe most basic possiblePhoenix LiveView Appwithno prior experience necessary.

LiveView?

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 theLiveBeats Demo:livebeats.fly.dev😍 🤯 🙏


Who? 👤

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.

Please Star! ⭐

This is the tutorial wewish we'd had when we first started usingLiveView ...
If you find it useful, please give it a star ⭐ it onGithubso that otherpeople will discover it.

Thanks! 🙏


Prerequisites: What you NeedBefore You Start 📝

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.3which 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


How? 💻

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


Step 0: Run theFinished Counter App on yourlocalhost 🏃‍

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.

Clone the Repository

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

Download the Dependencies

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.😉

Run the App

Start the Phoenix server by running the command:

mix phx.server

Now you can visitlocalhost:4000in 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:

phoenix-liveview-counter-start

With thefinished version of the App running on your machineand a clear picture of where we are headed, it's time tobuild it!


Step 1: Create the App 🆕

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: SincePhoenix1.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.


Checkpoint 1:Run theTests!

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.


Checkpoint 1b:Run the New Phoenix App!

Run the server by executing this command:

mix phx.server

Visitlocalhost:4000in your web browser.

You should see something similar to the following:

welcome-to-phoenix


Step 2: Create thecounter.ex File

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

Explanation of the Code

The first line instructs Phoenix to use thePhoenix.LiveViewbehaviour.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/3function to assign the:val key a value of0 on thesocket.That just means the socket will now have a:valwhich is initialized to0.


The second function ishandle_event/3which 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 thesocketto 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.

InElixir 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/1receives 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~Hsigilis 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


Step 3: Create thelive Route inrouter.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.exfile and locate the block of codethat starts withscope "/", CounterWeb do:

scope"/",CounterWebdopipe_through:browserget"/",PageController,:indexend

Replace the lineget "/", PageController, :indexwithlive("/", Counter).So you end up with:

scope"/",CounterWebdopipe_through:browserlive("/",Counter)end

3.1 Update the Failing Test Assertion

Since we have replaced theget "/", PageController, :index route inrouter.exin the previous step, the test intest/counter_web/controllers/page_controller_test.exswill 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\">&rarr;</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.exsfile 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"

🏁 Thepage_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

Checkpoint: Run Counter App!

Now that all the code for thecounter.ex is written,run the Phoenix app with the following command:

mix phx.server

Vistlocalhost:4000in your web browser.

You should expect to see a fully functioningLiveView counter:

liveview-counter-1.7.7


Recap: Working CounterWithout WritingJavaScript!

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.exthat 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:

phoenix-liveview-counter-two-windows-independent-count

If we want toshare thecounter state between multiple clients,we need to add a bit more code.


Step 4:Share State Between Clients!

One of the biggest selling pointsof usingPhoenix to build web appsis the built-in support forWebSocketsin 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.exfile 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

Code Explanation

The first change is onLine 4@topic "live" defines a module attribute(think of it as a global constant),that lets us to reference@topicanywhere in the file.

The second change is onLine 7where themount/3function 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@topicso when the count is updated on any of the clients,all the other clients see the same value.

Next we update the firsthandle_event/3function 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_stateso that we can use it on the next two lines.InvokingCounterWeb.Endpoint.broadcast_fromsends 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.Socketsocket:

#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 keyvalwhere 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/2handlesElixir 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


Checkpoint: Run It!

Now thatcounter.exhas 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:4000in as many web browsers as you haveand test the increment/decrement buttons!

You should see the count increasing/decreasing in all browsers simultaneously!

phoenix-liveview-counter-four-windows


Congratulations! 🎉

You just built a real-time counterthat seamlessly updates all connected clientsusingPhoenix LiveViewin less than40 lines of code!


Tests! 🧪

before we get carried away celebrating that we'vefinished the counter,Let's make sure that all the functionality, however basic, is fully tested.

Addexcoveralls to Check/Track Coverage

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!

Create acoveralls.json File

In the root of the project,create a file calledcoveralls.jsonand 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.

DELETE 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.exsto: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:

image

This shows us which functions/lines arenot being covered by ourexisting tests.


Add More Tests!

Open thetest/counter_web/live/counter_test.exsand 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 cYou 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. ✅

Bonus Level: Use aLiveView Component (Optional)

At present therender/1function incounter.exhas 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 ourMPVit's a good idea tosplit the template into aseparate fileto make it easier to read and maintain.

This is wherePhoenix.LiveComponentcomes to the rescue!LiveComponents are a mechanismto compartmentalize state, markup,and events inLiveView.

Create aLiveView Component

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

🏁 Yourcounter_component.ex should look like this:lib/counter_web/live/counter_component.ex

The component has a similarrender/1function to what was incounter.ex.That's the point; we just want it in a separate filefor maintainability.


Re-run thecounter Appusingmix phx.serverand confirm everything still works:

phoenix-liveview-counter-component

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%----------------

Moving state out of theLiveView

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 theLiveViewsto 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.exand 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_callfunctions 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, theGenServerto all the activeLiveView clients.

Update the Tests forGenServer State

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.exsfile 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%----------------

How manypeople are viewing the Counter?

Phoenix has a very cool feature calledPresenceto 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 theApplicationwe are going to usePresence.For this we need to create alib/counter/presence.exfile 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.exfile (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.

  1. We subscribe to and participate in the Presence system (we do that inmount)
  2. We handle Presence updates and use the current count, adding joiners andsubtracting leavers to calculate the current numbers 'present'. We do thatin a pattern matchedhandle_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.
  3. We publish the additional data to the client inrender
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.exand 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.

dwyl-liveview-counter-presence-genserver-state


More Tests!

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%----------------

Done! 🏁

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!


What'sNext?

If you've enjoyed this basiccounter tutorialand want something a bit more advanced,checkout ourLiveViewChat Tutorial:github.com/dwyl/phoenix-liveview-chat-example 💬
Then if you want a more advanced "real world" Appthat usesLiveViewextensivelyincludingAuthentication and some client-sideJS,checkout ourMVP App/dwyl/mvp📱⏳✅ ❤️



Feedback 💬 🙏

Several people in theElixir /Phoenix communityhave found this tutorial helpful when starting to useLiveView,e.g: Kurt Mackey@mrkurttwitter.com/mrkurt/status/1362940036795219973

mrkurt-liveview-tweet

He deployed the counter app to a 17 region cluster using fly.io:https://liveview-counter.fly.dev

liveview-counter-cluster

Code:https://github.com/fly-apps/phoenix-liveview-cluster/blob/master/lib/counter_web/live/counter.ex

Your feedback is always very muchwelcome! 🙏



Credits + Thanks! 🙌

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

dennisbeatty-counter-video

We recommendeveryone learningElixirsubscribe to his YouTube channel and watchall his videosas they are asuperb resource!

The 3 key differencesbetween this tutorial and Dennis' original post are:

  1. 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.
  2. LatestPhoenix,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.
  3. 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" ofLiveView!

Phoenix LiveView for Web Developers Who Don't knowElixir

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

phoenix-liveview-intro-

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_example
chris-phoenix-live-view-example-rainbowIt'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

chris-keynote-elixirconf-eu-2019

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

Sophie-DeBenedetto-elixir-conf-2019-talk

Related blog post:https://elixirschool.com/blog/live-view-live-component/

Relevant + Recommended Reading

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

Stars

Watchers

Forks


[8]ページ先頭

©2009-2025 Movatter.jp