Current section
Files
Jump to
Current section
Files
lib/phoenix/session_process/redux/selector.ex
defmodule Phoenix.SessionProcess.Redux.Selector do
@moduledoc """
Memoized selectors for efficient state extraction from Redux stores.
Selectors allow you to extract and compute derived state from Redux stores
with automatic memoization to prevent unnecessary recomputation.
## Basic Usage
# Simple selector
user_selector = fn state -> state.user end
user = Selector.select(redux, user_selector)
# Composed selector with memoization
expensive_selector = Selector.create_selector(
[
fn state -> state.items end,
fn state -> state.filter end
],
fn items, filter ->
# Expensive computation only runs when items or filter change
Enum.filter(items, fn item -> item.category == filter end)
end
)
result = Selector.select(redux, expensive_selector)
## How Memoization Works
- Simple selectors (functions) are executed on every call
- Composed selectors (created with `create_selector/2`) cache results
- Cache key is based on input values from dependency selectors
- When dependencies return same values, cached result is returned
- Cache is stored per-process in process dictionary for thread safety
## Reselect-style Composition
Like Redux's reselect library, you can compose selectors:
user_id_selector = fn state -> state.current_user_id end
users_selector = fn state -> state.users end
current_user_selector = Selector.create_selector(
[user_id_selector, users_selector],
fn user_id, users -> Map.get(users, user_id) end
)
## Performance Benefits
- Avoids expensive computations when state hasn't changed
- Enables shallow equality checks in subscriptions
- Reduces unnecessary re-renders in LiveView
- Supports deeply nested state selection
## Thread Safety
Selector caches are stored in the process dictionary, making them
process-safe. Each process maintains its own cache.
"""
alias Phoenix.SessionProcess.Redux
@type selector_fn :: (map() -> any())
@type composed_selector :: %{
deps: [selector_fn()],
compute: ([any()] -> any()),
cache_key: reference()
}
@type selector :: selector_fn() | composed_selector()
@cache_key :phoenix_session_process_selector_cache
@doc """
Execute a selector against a Redux store's current state.
## Examples
# Simple selector
selector = fn state -> state.count end
count = Selector.select(redux, selector)
# Composed selector (memoized)
composed = Selector.create_selector(
[fn state -> state.items end],
fn items -> Enum.count(items) end
)
count = Selector.select(redux, composed)
"""
@spec select(Redux.t(), selector()) :: any()
def select(redux, %{deps: deps, compute: compute, cache_key: cache_key}) do
state = Redux.get_state(redux)
# Extract values from dependency selectors recursively
dep_values =
Enum.map(deps, fn dep ->
# Handle both simple selectors and composed selectors
if is_function(dep, 1) do
dep.(state)
else
# Recursively select for composed dependency
select(redux, dep)
end
end)
# Check cache
case get_cached_result(cache_key, dep_values) do
{:hit, result} ->
result
:miss ->
# Compute new result
result = apply(compute, dep_values)
# Cache it
put_cached_result(cache_key, dep_values, result)
result
end
end
def select(redux, selector) when is_function(selector, 1) do
state = Redux.get_state(redux)
selector.(state)
end
@doc """
Create a memoized selector with dependency selectors.
The compute function receives the results of all dependency selectors
as separate arguments.
## Examples
# Select filtered items
selector = Selector.create_selector(
[
fn state -> state.items end,
fn state -> state.filter end
],
fn items, filter ->
Enum.filter(items, &(&1.type == filter))
end
)
# Compose multiple levels
base_selector = fn state -> state.data end
derived_selector = Selector.create_selector(
[base_selector],
fn data -> process(data) end
)
final_selector = Selector.create_selector(
[derived_selector],
fn processed -> aggregate(processed) end
)
"""
@spec create_selector([selector_fn()], function()) :: composed_selector()
def create_selector(deps, compute) when is_list(deps) and is_function(compute) do
# Validate compute function arity matches dependency count
arity = length(deps)
case :erlang.fun_info(compute, :arity) do
{:arity, ^arity} ->
%{
deps: deps,
compute: compute,
cache_key: make_ref()
}
{:arity, actual_arity} ->
raise ArgumentError,
"Compute function arity (#{actual_arity}) does not match " <>
"number of dependency selectors (#{arity})"
end
end
@doc """
Clear the selector cache for the current process.
Useful for testing or when you want to force recomputation.
## Examples
Selector.clear_cache()
"""
@spec clear_cache() :: :ok
def clear_cache do
Process.delete(@cache_key)
:ok
end
@doc """
Clear the cache for a specific selector.
## Examples
selector = Selector.create_selector([...], fn ... end)
Selector.clear_selector_cache(selector)
"""
@spec clear_selector_cache(composed_selector()) :: :ok
def clear_selector_cache(%{cache_key: cache_key}) do
cache = get_cache()
new_cache = Map.delete(cache, cache_key)
Process.put(@cache_key, new_cache)
:ok
end
def clear_selector_cache(_), do: :ok
@doc """
Get cache statistics for monitoring and debugging.
Returns a map with:
- `:entries` - Number of cached entries
- `:selectors` - Number of unique selectors with cache
## Examples
stats = Selector.cache_stats()
# => %{entries: 5, selectors: 2}
"""
@spec cache_stats() :: %{entries: non_neg_integer(), selectors: non_neg_integer()}
def cache_stats do
cache = get_cache()
entries =
cache
|> Map.values()
|> Enum.map(&map_size/1)
|> Enum.sum()
%{
entries: entries,
selectors: map_size(cache)
}
end
# Private functions
defp get_cache do
Process.get(@cache_key, %{})
end
defp get_cached_result(cache_key, dep_values) do
cache = get_cache()
case cache do
%{^cache_key => selector_cache} ->
# Create a cache key from dependency values
value_key = :erlang.phash2(dep_values)
case selector_cache do
%{^value_key => result} -> {:hit, result}
_ -> :miss
end
_ ->
:miss
end
end
defp put_cached_result(cache_key, dep_values, result) do
cache = get_cache()
value_key = :erlang.phash2(dep_values)
selector_cache =
cache
|> Map.get(cache_key, %{})
|> Map.put(value_key, result)
new_cache = Map.put(cache, cache_key, selector_cache)
Process.put(@cache_key, new_cache)
:ok
end
end