CacheMeIfYouCan
Computed properties for Phoenix LiveView
Installation
Add :cache_me_if_you_can to your list of dependencies in mix.exs:
def deps do
[
{:cache_me_if_you_can, "~> 0.2"}
]
end
Overview
This package provides reactive property caching for LiveView for automatically recomputing
properties when any of the declared dependencies change. This allows useMemo-like functionality
with a more explicit functional api that fits into the LiveView and BEAM state model.
Dependencies are declared with a module attribute @reactive_cache and cache tracking is managed
by using the CacheMeIfYouCan.LiveViewCache.assign_cached/3 function.
This allows complete control over which events trigger a recomputation since we can always use the
Phoenix.Component.assign/3 function to assign a dependency without triggering a recomputation.
See the documentation for the CacheMeIfYouCan.ReactiveCache struct module for a detailed
explanation of how to use the @reactive_cache module attribute.
Performance
This library should have a negligible impact on LiveView performance. Almost everything this library provides happens at compile time.
There is a very small runtime cost when using the CacheMeIfYouCan.LiveViewCache.assign_cached/3
function instead of the Phoenix.Component.assign/3 function since we have to map over the
cached properties that depend on the assigned key and trigger their configured callbacks.
In practice, this should be completely negligible. However, for best performance, the functions configured to recompute the cached values should use async assigns/streams/callbacks to handle the updates in the background.
Quickstart Example
defmodule MyApp.UsersLive do
@moduledoc """
A live list of users that can be filtered by role and status and sorted on username.
Pagination, sorting, and filtering are all driven by the query params in the URL.
The list of users and the total page count will be automatically recomputed when
any of the constituent data points are updated via the query params.
"""
use Phoenix.LiveView
# We have to `use` the `LiveViewCache` in order to initialize the `@reactive_cache`s.
# This must come after the `use Phoenix.LiveView` call or the on_mount hook will not be registered.
use CacheMeIfYouCan.LiveViewCache
# Configure the cached/computed properties and their deps at compile-time.
@reactive_cache [
key: :user_list,
default_value: [],
deps: [:sort_order, :page_no, :page_size, :filters],
cb: &__MODULE__.refresh_data/2,
]
@reactive_cache [
key: :page_count,
default_value: 1,
deps: [:page_size, :filters],
cb: &__MODULE__.refresh_data/2,
]
# Callback to compute the :user_list cached assign.
def refresh_data(socket, :user_list) when is_reactive(socket) do
%{filters: filters} = socket.assigns
%{sort_order: sort_order} = socket.assigns
%{page_no: page_no, page_size: page_size} = socket.assigns
stream_async(socket, :user_list, fn ->
res =
from(User)
|> filter_user_query(filters)
|> order_by([user: u], {^sort_order, :username})
|> limit(^page_size)
|> offset((^page_no - 1) * ^page_size)
|> Repo.all()
{:ok, res, reset: true}
end)
end
# Callback to compute the :page_count cached assign.
def refresh_data(socket, :page_count) when is_reactive(socket) do
%{filters: filters, page_size: page_size} = socket.assigns
start_async(socket, :fetch_page_count, fn ->
from(User)
|> filter_user_query(filters)
|> Repo.aggregate(:count)
|> (&(&1 / page_size)).()
|> Float.ceil()
|> trunc()
end)
end
defp filter_user_query(query, filters) do
Enum.reduce(filters, query, fn {column, value}, acc ->
where(acc, [user: u], field(u, ^column == ^value))
end)
end
@impl true
def handle_async(:fetch_page_count, {:ok, 0 = _total_page_count}, socket) do
updated_socket =
socket
|> assign_cached(:page_no, 1)
|> assign(:page_count, 1)
{:noreply, updated_socket}
end
@impl true
def handle_async(:fetch_page_count, {:ok, total_page_count}, socket) do
updated_socket =
if socket.assigns.page_no > total_page_count do
socket
# We use `assign_cached/3` for the `:page_no` here so that it
# will keep the `:user_list` up to date as well.
|> assign_cached(:page_no, total_page_count)
|> assign(:page_count, total_page_count)
else
socket
|> assign(:page_count, total_page_count)
end
{:noreply, updated_socket}
end
@impl true
def mount(_params, _session, socket) do
mounted_socket =
socket
# Helper function to initialize the cached properties after assigning their deps.
|> assign_new_cached(:filters, fn -> [] end)
|> assign_new_cached(:sort_order, fn -> :asc end)
|> assign_new_cached(:page_no, fn -> 1 end)
|> assign_new_cached(:page_size, fn -> 25 end)
{:ok, mounted_socket}
end
@impl true
def handle_params(params, _uri, socket) do
# Computed properties allow us to keep the `:user_list` updated with simple
# declarative assigns from the GET params.
updated_socket =
socket
|> parse_params(params, "sort", :sort_order)
|> parse_params(params, "page", :page_no)
|> parse_params(params, "per-page", :page_size)
|> parse_filter_params(params)
{:noreply, updated_socket}
end
defp parse_params(socket, params, param_key, assigns_key) when is_atom(assigns_key) do
case Map.get(params, param_key, nil) do
nil -> socket
val -> assign_cached(socket, assigns_key, val)
end
end
defp parse_filter_params(socket, %{"filter-by" => keys, "filter" => values}) do
valid_columns = %{"userrole" => :role, "userstatus" => :status}
new_filters =
Enum.zip(keys, values)
|> Enum.reduce([], fn {key, val} ->
case Map.get(valid_columns, key, nil) do
nil -> acc
col_name -> [{col_name, val} | acc]
end
end)
assign_cached(socket, :filters, new_filters)
end
defp parse_filter_params(socket, %{}), do: socket
end