--- name: phoenix-pubsub-patterns description: Use when adding real-time updates via Phoenix.PubSub — subscribe/broadcast topology, topic naming, handle_info message handling. file_patterns: - "**/*_live.ex" - "**/*_live/*.ex" - "**/contexts/**/*.ex" auto_suggest: true --- # Phoenix PubSub Patterns ## RULES — Follow these with no exceptions 1. **Always guard subscriptions with `if connected?(socket)`** — the disconnected render runs in a separate short-lived process; subscribing there is wasted work, not a duplicate (LiveView mounts twice: once static, once connected) 2. **Broadcast from contexts, not LiveViews** — keeps real-time logic in the business layer; LiveViews only subscribe and react 3. **Use consistent topic naming** — `"resource:id"` for specific resources, `"resource:action"` for collection-wide events 4. **Handle PubSub messages in `handle_info/2`** — never in `handle_event/3`; PubSub messages are process messages, not client events 5. **Prefer `update/3` when the new value derives from the old** — `update(socket, :posts, &[post | &1])` reads better than reaching into `socket.assigns` manually. Both are equivalent; this is style, not safety. 6. **Test PubSub by calling context functions and asserting LiveView updates** — don't test `PubSub.broadcast` directly; test the full cycle --- ## Subscription Pattern Subscribe in `mount/3` only when connected. The static render doesn't need real-time updates. ```elixir defmodule MyAppWeb.PostLive.Index do use MyAppWeb, :live_view @impl true def mount(_params, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(MyApp.PubSub, "posts") end {:ok, assign(socket, :posts, list_posts())} end @impl true def handle_info({:post_created, post}, socket) do {:noreply, update(socket, :posts, fn posts -> [post | posts] end)} end @impl true def handle_info({:post_updated, post}, socket) do {:noreply, update(socket, :posts, fn posts -> Enum.map(posts, fn p when p.id == post.id -> post p -> p end) end)} end @impl true def handle_info({:post_deleted, post}, socket) do {:noreply, update(socket, :posts, fn posts -> Enum.reject(posts, &(&1.id == post.id)) end)} end end ``` --- ## Broadcasting from Contexts Broadcast after successful database operations. The context owns the business logic — LiveViews are just subscribers. ```elixir defmodule MyApp.Blog do alias MyApp.Blog.Post alias MyApp.Repo def create_post(attrs) do %Post{} |> Post.changeset(attrs) |> Repo.insert() |> broadcast(:post_created) end def update_post(%Post{} = post, attrs) do post |> Post.changeset(attrs) |> Repo.update() |> broadcast(:post_updated) end def delete_post(%Post{} = post) do post |> Repo.delete() |> broadcast(:post_deleted) end # Only broadcast on success defp broadcast({:ok, post}, event) do Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {event, post}) {:ok, post} end defp broadcast({:error, changeset}, _event) do {:error, changeset} end end ``` --- ## Topic Naming Conventions Use a consistent naming scheme so subscribers know what to expect. ```elixir # Collection-wide — all posts topic = "posts" # Events: {:post_created, post}, {:post_updated, post}, {:post_deleted, post} # Specific resource — one post topic = "posts:#{post.id}" # Events: {:post_updated, post}, {:post_deleted, post}, {:comment_added, comment} # User-scoped — all activity for a user topic = "users:#{user.id}" # Events: {:notification, notification}, {:message_received, message} ``` ### Subscribing to Specific Resources ```elixir @impl true def mount(%{"id" => id}, _session, socket) do post = Blog.get_post!(id) if connected?(socket) do Phoenix.PubSub.subscribe(MyApp.PubSub, "posts:#{post.id}") end {:ok, assign(socket, :post, post)} end ``` --- ## Scoped Broadcasting When events should only reach specific users or resources: ```elixir # In context — broadcast to resource-specific topic defp broadcast({:ok, comment}, :comment_added) do Phoenix.PubSub.broadcast( MyApp.PubSub, "posts:#{comment.post_id}", {:comment_added, comment} ) {:ok, comment} end # In context — broadcast to user-specific topic defp broadcast({:ok, notification}, :new_notification) do Phoenix.PubSub.broadcast( MyApp.PubSub, "users:#{notification.user_id}", {:new_notification, notification} ) {:ok, notification} end ``` --- ## Deriving New Assigns with `update/3` Use `update/3` when the new value derives from the old one — it reads better than reaching into `socket.assigns` manually. Functionally, `assign/3` and `update/3` produce identical results here; this is a style preference, not a correctness rule. ```elixir # Equivalent — reaches into socket.assigns directly def handle_info({:post_created, post}, socket) do {:noreply, assign(socket, :posts, [post | socket.assigns.posts])} end # Preferred — update/3 reads better when deriving from the old value def handle_info({:post_created, post}, socket) do {:noreply, update(socket, :posts, fn posts -> [post | posts] end)} end # Good — update a specific item in the list def handle_info({:post_updated, updated_post}, socket) do {:noreply, update(socket, :posts, fn posts -> Enum.map(posts, fn post when post.id == updated_post.id -> updated_post post -> post end) end)} end # Good — remove an item from the list def handle_info({:post_deleted, deleted_post}, socket) do {:noreply, update(socket, :posts, fn posts -> Enum.reject(posts, &(&1.id == deleted_post.id)) end)} end ``` --- ## Testing PubSub Test the full cycle: call a context function, assert the LiveView updates. Don't test `PubSub.broadcast` in isolation. ```elixir describe "real-time updates" do test "new post appears in list", %{conn: conn} do user = user_fixture() conn = log_in_user(conn, user) {:ok, lv, _html} = live(conn, ~p"/posts") # Create a post through the context (triggers broadcast) {:ok, post} = Blog.create_post(%{title: "New Post", user_id: user.id}) # Assert the LiveView received and rendered the update assert render(lv) =~ "New Post" end test "updated post reflects changes", %{conn: conn} do user = user_fixture() post = post_fixture(user_id: user.id, title: "Original") conn = log_in_user(conn, user) {:ok, lv, _html} = live(conn, ~p"/posts") assert render(lv) =~ "Original" {:ok, _post} = Blog.update_post(post, %{title: "Updated"}) assert render(lv) =~ "Updated" refute render(lv) =~ "Original" end test "deleted post disappears from list", %{conn: conn} do user = user_fixture() post = post_fixture(user_id: user.id, title: "To Delete") conn = log_in_user(conn, user) {:ok, lv, _html} = live(conn, ~p"/posts") assert render(lv) =~ "To Delete" {:ok, _post} = Blog.delete_post(post) refute render(lv) =~ "To Delete" end end ``` --- See `phoenix-liveview-essentials` skill for LiveView lifecycle patterns. See `testing-essentials` skill for comprehensive testing patterns.