CacheMeIfYouCan

View Source

Computed properties for Phoenix LiveView

Hex Docs

Installation

Add :cache_me_if_you_can to your list of dependencies in mix.exs:

def deps do
  [
    {:cache_me_if_you_can, "~> 0.1"}
  ]
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.

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
  use CacheMeIfYouCan.LiveViewCache

  @reactive_cache [
    key: :user_list,
    default_value: [],
    deps: [:sort_order, :page_no, :page_size, :filters],
    cb: &__MODULE__.refresh_user_list/1,
  ]
  @reactive_cache [
    key: :page_count,
    default_value: 1,
    deps: [:page_size, :filters],
    cb: &__MODULE__.refresh_page_count/1,
  ]

  def refresh_user_list(socket) 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

  def refresh_page_count(socket) 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
        |> 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
      |> 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
    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