Skip to content

Commit dd43e3c

Browse files
committed
Add assign_new/4 that supports conditional assign based on deps
1 parent 26acd30 commit dd43e3c

File tree

6 files changed

+307
-0
lines changed

6 files changed

+307
-0
lines changed

lib/phoenix_component.ex

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,50 @@ defmodule Phoenix.Component do
13131313
raise_bad_socket_or_assign!("assign_new/3", assigns)
13141314
end
13151315

1316+
@doc """
1317+
Assigns the given `key` with value from `fun` into `socket` if one does not yet exist
1318+
or if any of the dependencies have changed.
1319+
1320+
This function is similar to `assign_new/3` but adds support for dependencies. When any
1321+
of the dependencies listed in `deps` have changed (as tracked by the socket's `__changed__`
1322+
map), the function will be called to compute a new value for `key`.
1323+
1324+
This is particularly useful for extracting common logic that depends on certain assigns
1325+
and needs to be recomputed only when those specific assigns change. It helps write
1326+
in a more declarative style by allowing the same function to be called in multiple
1327+
event handlers.
1328+
1329+
## Examples
1330+
1331+
Imagine a LiveView that loads a user based on a user_id, and needs to recompute
1332+
user-related data whenever the user_id changes:
1333+
1334+
# In your LiveView module
1335+
def mount(_params, _session, socket) do
1336+
{:ok, assign(socket, user_id: nil) |> load_user_data()}
1337+
end
1338+
1339+
def handle_event("select_user", %{"id" => user_id}, socket) do
1340+
{:noreply, assign(socket, user_id: user_id) |> assign_dependencies()}
1341+
end
1342+
1343+
# This shared function keeps all dependent data in sync
1344+
# It will only recompute values when their dependencies change
1345+
defp assign_dependencies(socket) do
1346+
socket
1347+
|> assign_new(:user, [:user_id], &load_user(&1.user_id))
1348+
end
1349+
1350+
In this example, `:user` will be recomputed whenever `:user_id` changes.
1351+
1352+
The function can be either a zero-arity function or a one-arity function that receives
1353+
the current assigns.
1354+
"""
1355+
def assign_new(%Socket{} = socket, key, deps, fun) when is_list(deps) do
1356+
validate_assign_key!(key)
1357+
Phoenix.LiveView.Utils.assign_new(socket, key, deps, fun)
1358+
end
1359+
13161360
defp raise_bad_socket_or_assign!(name, assigns) do
13171361
extra =
13181362
case assigns do

lib/phoenix_live_view/utils.ex

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,74 @@ defmodule Phoenix.LiveView.Utils do
8383
end
8484
end
8585

86+
@doc """
87+
Assigns the given `key` with value from `fun` into `socket` if one does not yet exist
88+
or if any of the dependencies have changed.
89+
90+
This function is particularly useful for computing derived values that depend on other assigns.
91+
When any of the dependencies listed in `deps` have changed (as tracked by the socket's `__changed__`
92+
map), the function will be called to compute a new value for `key`.
93+
94+
## Example
95+
96+
Imagine a LiveView that manages a user profile where we need to fetch user data when the user ID changes.
97+
We can factor out the refresh logic into a common function:
98+
99+
def mount(_params, _session, socket) do
100+
{:ok, assign(socket, :user_id, nil) |> refresh_assigns()}
101+
end
102+
103+
def handle_event("select_user", %{"id" => user_id}, socket) do
104+
{:noreply, socket |> assign(:user_id, user_id) |> refresh_assigns()}
105+
end
106+
107+
def handle_event("update_profile", params, socket) do
108+
{:noreply, socket |> handle_profile_update(params) |> refresh_assigns()}
109+
end
110+
111+
def handle_params(%{"user_id" => user_id}, _uri, socket) do
112+
{:noreply, socket |> assign(:user_id, user_id) |> refresh_assigns()}
113+
end
114+
115+
# Centralized refresh function that can be called from any event handler
116+
defp refresh_assigns(socket) do
117+
socket
118+
|> assign_new(:user, [:user_id], &fetch_user_data/1)
119+
end
120+
121+
defp fetch_user_data(%{user_id: user_id}) when is_nil(user_id), do: nil
122+
defp fetch_user_data(%{user_id: user_id}) do
123+
# This expensive database query only runs when user_id changes
124+
Repo.get(User, user_id)
125+
end
126+
127+
In this example, we've factored out the assign refresh logic into a common `refresh_assigns/1`
128+
function that we can call from any event handler or callback. The `user` struct is only fetched
129+
when `user_id` changes, even though we call `refresh_assigns/1` in multiple places.
130+
131+
This approach keeps your code DRY and ensures expensive operations only run when necessary.
132+
"""
133+
def assign_new(%Socket{} = socket, key, deps, fun)
134+
when is_list(deps) and
135+
(is_function(fun, 0) or is_function(fun, 1)) do
136+
# If any dependency has changed, always recompute the value
137+
if deps_in_changed?(socket, deps) do
138+
case fun do
139+
fun when is_function(fun, 1) -> force_assign(socket, key, fun.(socket.assigns))
140+
fun when is_function(fun, 0) -> force_assign(socket, key, fun.())
141+
end
142+
else
143+
# Otherwise, use the standard assign_new behavior
144+
assign_new(socket, key, fun)
145+
end
146+
end
147+
148+
defp deps_in_changed?(%Socket{assigns: %{__changed__: changed}}, deps) when is_map(changed) do
149+
Enum.any?(deps, &Map.has_key?(changed, &1))
150+
end
151+
152+
defp deps_in_changed?(%Socket{}, _deps), do: false
153+
86154
@doc """
87155
Forces an assign on a socket.
88156
"""

test/phoenix_component_test.exs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,124 @@ defmodule Phoenix.ComponentUnitTest do
170170
end
171171
end
172172

173+
describe "assign_new with dependencies" do
174+
test "recomputes value when dependencies change" do
175+
socket =
176+
@socket
177+
|> assign(user_id: 123)
178+
|> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end)
179+
180+
assert socket.assigns.user == "User 123"
181+
182+
# Change a dependency
183+
socket =
184+
socket
185+
|> Utils.clear_changed()
186+
|> assign(user_id: 456)
187+
|> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end)
188+
189+
assert socket.assigns.user == "User 456"
190+
assert changed?(socket, :user)
191+
end
192+
193+
test "doesn't recompute when dependencies don't change" do
194+
socket =
195+
@socket
196+
|> assign(user_id: 123, counter: 0)
197+
|> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end)
198+
199+
assert socket.assigns.user == "User 123"
200+
201+
# Change an unrelated assign
202+
socket =
203+
socket
204+
|> Utils.clear_changed()
205+
|> assign(counter: 1)
206+
|> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end)
207+
208+
assert socket.assigns.user == "User 123"
209+
refute changed?(socket, :user)
210+
end
211+
212+
test "works with zero-arity functions" do
213+
socket =
214+
@socket
215+
|> assign(user_id: 123)
216+
|> assign_new(:random_value, [:user_id], fn -> :rand.uniform(100) end)
217+
218+
assert is_integer(socket.assigns.random_value)
219+
assert socket.assigns.random_value > 0 && socket.assigns.random_value <= 100
220+
221+
# Change a dependency
222+
socket =
223+
socket
224+
|> Utils.clear_changed()
225+
|> assign(user_id: 456)
226+
|> assign_new(:random_value, [:user_id], fn -> :rand.uniform(100) end)
227+
228+
# Value should be recomputed
229+
assert is_integer(socket.assigns.random_value)
230+
assert socket.assigns.random_value > 0 && socket.assigns.random_value <= 100
231+
# It's theoretically possible but extremely unlikely that we'd get the same random number
232+
assert changed?(socket, :random_value)
233+
end
234+
235+
test "works with multiple dependencies" do
236+
socket =
237+
@socket
238+
|> assign(first_name: "John", last_name: "Doe")
239+
|> assign_new(:full_name, [:first_name, :last_name], fn %{first_name: first, last_name: last} ->
240+
"#{first} #{last}"
241+
end)
242+
243+
assert socket.assigns.full_name == "John Doe"
244+
245+
# Change first dependency
246+
socket =
247+
socket
248+
|> Utils.clear_changed()
249+
|> assign(first_name: "Jane")
250+
|> assign_new(:full_name, [:first_name, :last_name], fn %{first_name: first, last_name: last} ->
251+
"#{first} #{last}"
252+
end)
253+
254+
assert socket.assigns.full_name == "Jane Doe"
255+
assert changed?(socket, :full_name)
256+
257+
# Change second dependency
258+
socket =
259+
socket
260+
|> Utils.clear_changed()
261+
|> assign(last_name: "Smith")
262+
|> assign_new(:full_name, [:first_name, :last_name], fn %{first_name: first, last_name: last} ->
263+
"#{first} #{last}"
264+
end)
265+
266+
assert socket.assigns.full_name == "Jane Smith"
267+
assert changed?(socket, :full_name)
268+
end
269+
270+
test "behaves like assign_new when key doesn't exist" do
271+
socket =
272+
@socket
273+
|> assign(user_id: 123)
274+
|> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end)
275+
276+
assert socket.assigns.user == "User 123"
277+
assert changed?(socket, :user)
278+
end
279+
280+
test "uses parent assigns when present" do
281+
socket =
282+
put_in(@socket.private[:assign_new], {%{user_id: 999}, []})
283+
|> assign(user_id: 123)
284+
|> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end)
285+
286+
assert socket.assigns.user == "User 123"
287+
assert changed?(socket, :user)
288+
end
289+
end
290+
173291
describe "assign_new with assigns" do
174292
test "tracks changes" do
175293
assigns = assign_new(@assigns_changes, :key, fn -> raise "won't be invoked" end)

test/phoenix_live_view/integrations/assigns_test.exs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,38 @@ defmodule Phoenix.LiveView.AssignsTest do
5050
end
5151
end
5252

53+
describe "assign_new with dependencies" do
54+
test "recomputes value when dependencies change", %{conn: conn} do
55+
{:ok, view, _html} = live(conn, "/deps")
56+
57+
# Initial state
58+
assert render(view) =~ "user_id: 123"
59+
assert render(view) =~ "user_name: User 123"
60+
61+
# Update user_id which should trigger recomputation of user_name
62+
view |> element("button", "Change User ID") |> render_click()
63+
64+
assert render(view) =~ "user_id: 456"
65+
assert render(view) =~ "user_name: User 456"
66+
end
67+
68+
test "doesn't recompute when dependencies don't change", %{conn: conn} do
69+
{:ok, view, _html} = live(conn, "/deps")
70+
71+
# Initial state
72+
assert render(view) =~ "counter: 0"
73+
assert render(view) =~ "user_id: 123"
74+
assert render(view) =~ "user_name: User 123"
75+
76+
# Update counter which should not trigger recomputation of user_name
77+
view |> element("button", "Increment Counter") |> render_click()
78+
79+
assert render(view) =~ "counter: 1"
80+
assert render(view) =~ "user_id: 123"
81+
assert render(view) =~ "user_name: User 123"
82+
end
83+
end
84+
5385
describe "temporary assigns" do
5486
test "can be configured with mount options", %{conn: conn} do
5587
{:ok, conf_live, html} =
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
defmodule Phoenix.LiveViewTest.Support.DepsLive do
2+
use Phoenix.LiveView
3+
4+
def render(assigns) do
5+
~H"""
6+
<div>
7+
<div>counter: <%= @counter %></div>
8+
<div>user_id: <%= @user_id %></div>
9+
<div>user_name: <%= @user_name %></div>
10+
<button phx-click="increment">Increment Counter</button>
11+
<button phx-click="change_user_id">Change User ID</button>
12+
</div>
13+
"""
14+
end
15+
16+
def mount(_params, _session, socket) do
17+
socket =
18+
socket
19+
|> assign(:counter, 0)
20+
|> assign(:user_id, 123)
21+
|> assign_new(:user_name, [:user_id], fn %{user_id: user_id} ->
22+
# In a real app, this would be a database query
23+
"User #{user_id}"
24+
end)
25+
26+
{:ok, socket}
27+
end
28+
29+
def handle_event("increment", _, socket) do
30+
{:noreply, update(socket, :counter, &(&1 + 1))}
31+
end
32+
33+
def handle_event("change_user_id", _, socket) do
34+
socket =
35+
socket
36+
|> assign(:user_id, 456)
37+
|> assign_new(:user_name, [:user_id], fn %{user_id: user_id} ->
38+
# In a real app, this would be a database query
39+
"User #{user_id}"
40+
end)
41+
42+
{:noreply, socket}
43+
end
44+
end

test/support/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ defmodule Phoenix.LiveViewTest.Support.Router do
3636
live "/same-child", SameChildLive
3737
live "/root", RootLive
3838
live "/opts", OptsLive
39+
live "/deps", DepsLive
3940
live "/shuffle", ShuffleLive
4041
live "/components", WithComponentLive
4142
live "/multi-targets", WithMultipleTargets

0 commit comments

Comments
 (0)