Current section

Files

Jump to
phoenix_session_process lib phoenix session_process redux.ex
Raw

lib/phoenix/session_process/redux.ex

defmodule Phoenix.SessionProcess.Redux do
@moduledoc """
Redux-style state management for Phoenix Session Process.
This module provides a predictable state container with actions and reducers,
similar to the Redux pattern from JavaScript. It enables:
- Predictable state updates through actions and reducers
- Subscriptions for reactive state management
- Selectors with memoization for efficient derived state
- Time-travel debugging capabilities
- Middleware support for cross-cutting concerns
- Phoenix.PubSub integration for distributed state notifications
- LiveView integration with automatic assign updates
- Action history and replay
- Comprehensive telemetry events
## Basic Usage
```elixir
defmodule MyApp.SessionState do
use Phoenix.SessionProcess.Redux
@impl true
def init(_args) do
%{count: 0, user: nil}
end
@impl true
def reducer(state, action) do
case action do
{:increment, value} ->
%{state | count: state.count + value}
{:set_user, user} ->
%{state | user: user}
:reset ->
%{count: 0, user: nil}
_ ->
state
end
end
end
```
## Using in Session Process
```elixir
defmodule MyApp.SessionProcess do
use Phoenix.SessionProcess, :process
alias Phoenix.SessionProcess.Redux
@impl true
def init(_args) do
redux = Redux.init_state(%{count: 0, user: nil})
{:ok, %{redux: redux}}
end
def handle_call({:dispatch, action}, _from, state) do
new_redux = Redux.dispatch(state.redux, action, &reducer/2)
{:reply, {:ok, Redux.get_state(new_redux)}, %{state | redux: new_redux}}
end
def handle_call(:get_redux_state, _from, state) do
{:reply, {:ok, state.redux}, state}
end
defp reducer(state, action) do
case action do
{:increment, value} -> %{state | count: state.count + value}
{:set_user, user} -> %{state | user: user}
_ -> state
end
end
end
```
## Using Subscriptions
```elixir
# Subscribe to state changes
redux = Redux.subscribe(redux, fn state ->
IO.inspect(state, label: "State changed")
end)
# Subscribe with selector (only notifies when user changes)
user_selector = fn state -> state.user end
redux = Redux.subscribe(redux, user_selector, fn user ->
IO.inspect(user, label: "User changed")
end)
```
## Using Selectors
```elixir
alias Phoenix.SessionProcess.Redux.Selector
# Create memoized selector
expensive_selector = Selector.create_selector(
[fn state -> state.items end, fn state -> state.filter end],
fn items, filter ->
Enum.filter(items, &(&1.type == filter))
end
)
# Use selector
filtered = Selector.select(redux, expensive_selector)
```
## LiveView Integration
```elixir
defmodule MyAppWeb.DashboardLive do
use Phoenix.LiveView
alias Phoenix.SessionProcess.Redux.LiveView, as: ReduxLV
def mount(_params, %{"session_id" => session_id}, socket) do
# Auto-update assigns from Redux
socket = ReduxLV.assign_from_session(socket, session_id, %{
user: fn state -> state.user end,
count: fn state -> state.count end
})
{:ok, assign(socket, session_id: session_id)}
end
def handle_info({:redux_assign_update, key, value}, socket) do
{:noreply, ReduxLV.handle_assign_update(socket, key, value)}
end
end
```
## PubSub for Distributed State
```elixir
# Initialize with PubSub
redux = Redux.init_state(%{data: %{}},
pubsub: MyApp.PubSub,
pubsub_topic: "session:123"
)
# Dispatches will automatically broadcast via PubSub
redux = Redux.dispatch(redux, {:update_data, %{key: "value"}}, &reducer/2)
# Subscribe to broadcasts from other nodes
unsubscribe = Redux.subscribe_to_broadcasts(
MyApp.PubSub,
"session:123",
fn message -> IO.inspect(message) end
)
```
"""
@type state :: any()
@type action :: any()
@type reducer :: (state(), action() -> state())
@type middleware :: (action(), state(), (action() -> state()) -> state())
@type t :: %__MODULE__{
current_state: state(),
initial_state: state(),
history: list({action(), state()}),
reducer: reducer() | nil,
middleware: list(middleware()),
max_history_size: non_neg_integer(),
pubsub: module() | nil,
pubsub_topic: binary() | nil,
subscriptions: list(map())
}
@doc """
The Redux state structure containing current state and action history.
"""
defstruct [
:current_state,
:initial_state,
:history,
:reducer,
:middleware,
:max_history_size,
:pubsub,
:pubsub_topic,
subscriptions: []
]
@doc """
Initialize a new Redux state.
## Options
- `:reducer` - The reducer function to use
- `:middleware` - List of middleware functions
- `:max_history_size` - Maximum history entries (default: 100)
- `:pubsub` - Phoenix.PubSub module name for distributed notifications
- `:pubsub_topic` - Topic name for broadcasts (default: "redux:state_changes")
## Examples
# Basic usage
iex> redux = Redux.init_state(%{count: 0})
iex> Redux.current_state(redux)
%{count: 0}
# With PubSub for distributed notifications
iex> redux = Redux.init_state(%{count: 0},
...> pubsub: MyApp.PubSub,
...> pubsub_topic: "session:123:state"
...> )
"""
@spec init_state(state(), keyword()) :: %__MODULE__{}
def init_state(initial_state, opts \\ []) do
%__MODULE__{
current_state: initial_state,
initial_state: initial_state,
history: [],
reducer: Keyword.get(opts, :reducer, nil),
middleware: Keyword.get(opts, :middleware, []),
max_history_size: Keyword.get(opts, :max_history_size, 100),
pubsub: Keyword.get(opts, :pubsub, nil),
pubsub_topic: Keyword.get(opts, :pubsub_topic, "redux:state_changes")
}
end
@doc """
Dispatch an action to update the state.
## Examples
iex> redux = Redux.init_state(%{count: 0})
iex> redux = Redux.dispatch(redux, {:increment, 1}, fn state, {:increment, val} -> %{state | count: state.count + val} end)
iex> Redux.current_state(redux)
%{count: 1}
"""
@spec dispatch(%__MODULE__{}, action(), reducer()) :: %__MODULE__{}
def dispatch(redux, action, reducer) when is_function(reducer, 2) do
apply_action(redux, action, reducer)
end
@doc """
Dispatch an action using the built-in reducer.
Requires the Redux module to implement the reducer/2 callback.
"""
@spec dispatch(%__MODULE__{}, action()) :: %__MODULE__{}
def dispatch(redux, action) do
if function_exported?(__MODULE__, :reducer, 2) do
apply_action(redux, action, &__MODULE__.reducer/2)
else
raise "No reducer function defined. Use dispatch/3 or implement reducer/2"
end
end
defp apply_action(redux, action, reducer) do
# Apply middleware chain
# Apply in reverse order like Redux
middleware_list = Enum.reverse(redux.middleware)
# Base reducer that takes only action
base_reducer = fn act ->
reducer.(redux.current_state, act)
end
# Build middleware chain
final_reducer =
Enum.reduce(middleware_list, base_reducer, fn middleware, next ->
fn act ->
middleware.(act, redux.current_state, next)
end
end)
new_state = final_reducer.(action)
history_entry = %{
action: action,
previous_state: redux.current_state,
new_state: new_state,
timestamp: System.system_time(:millisecond)
}
new_history =
[history_entry | redux.history]
|> Enum.take(redux.max_history_size)
new_redux = %{redux | current_state: new_state, history: new_history}
# Notify subscriptions of state change
new_redux = notify_subscriptions(new_redux)
# Broadcast via PubSub if configured
broadcast_state_change(new_redux, action)
new_redux
end
@doc """
Get the current state.
## Examples
iex> redux = Redux.init_state(%{count: 0})
iex> Redux.current_state(redux)
%{count: 0}
"""
@spec current_state(%__MODULE__{}) :: state()
def current_state(redux), do: redux.current_state
@doc """
Alias for current_state/1. Used by Redux.Selector.
## Examples
iex> redux = Redux.init_state(%{count: 0})
iex> Redux.get_state(redux)
%{count: 0}
"""
@spec get_state(%__MODULE__{}) :: state()
def get_state(redux), do: current_state(redux)
@doc """
Get the initial state.
## Examples
iex> redux = Redux.init_state(%{count: 0})
iex> Redux.initial_state(redux)
%{count: 0}
"""
@spec initial_state(%__MODULE__{}) :: state()
def initial_state(redux), do: redux.initial_state
@doc """
Get the action history.
## Examples
iex> redux = Redux.init_state(%{count: 0})
iex> redux = Redux.dispatch(redux, {:increment, 1}, fn s, a -> %{s | count: s.count + elem(a, 1)} end)
iex> history = Redux.history(redux)
iex> length(history) == 1
true
"""
@spec history(%__MODULE__{}) :: list()
def history(redux), do: redux.history
@doc """
Reset to the initial state.
## Examples
iex> redux = Redux.init_state(%{count: 0})
iex> redux = Redux.dispatch(redux, {:increment, 1}, fn s, {:increment, val} -> %{s | count: s.count + val} end)
iex> redux = Redux.reset(redux)
iex> Redux.current_state(redux)
%{count: 0}
"""
@spec reset(%__MODULE__{}) :: %__MODULE__{}
def reset(redux) do
new_redux = %{redux | current_state: redux.initial_state, history: []}
# Notify subscriptions of state change
notify_subscriptions(new_redux)
end
@doc """
Time travel to a specific point in history.
## Examples
iex> redux = Redux.init_state(%{count: 0})
iex> redux = Redux.dispatch(redux, {:increment, 1}, fn s, {:increment, val} -> %{s | count: s.count + val} end)
iex> redux = Redux.dispatch(redux, {:increment, 2}, fn s, {:increment, val} -> %{s | count: s.count + val} end)
iex> redux = Redux.time_travel(redux, 1)
iex> Redux.current_state(redux)
%{count: 1}
"""
@spec time_travel(%__MODULE__{}, integer()) :: %__MODULE__{}
def time_travel(redux, steps_back) when steps_back >= 0 do
if steps_back > length(redux.history) do
raise "Cannot time travel beyond history length"
end
target_state =
redux.history
|> Enum.drop(steps_back)
|> Enum.reverse()
|> Enum.reduce(redux.initial_state, fn %{action: action}, acc_state ->
if redux.reducer do
redux.reducer.(acc_state, action)
else
raise "No reducer function defined for time travel. " <>
"Initialize Redux with a reducer: Redux.init_state(state, reducer: &my_reducer/2)"
end
end)
new_redux = %{
redux
| current_state: target_state,
history: Enum.drop(redux.history, steps_back)
}
# Notify subscriptions of state change
notify_subscriptions(new_redux)
end
@doc """
Add middleware to the Redux pipeline.
## Examples
iex> redux = Redux.init_state(%{count: 0})
iex> logger = fn action, state, next -> next.(action) end
iex> redux = Redux.add_middleware(redux, logger)
iex> Redux.middleware(redux) |> length()
1
"""
@spec add_middleware(%__MODULE__{}, middleware()) :: %__MODULE__{}
def add_middleware(redux, middleware) do
%{redux | middleware: [middleware | redux.middleware]}
end
@doc """
Get the list of middleware functions.
"""
@spec middleware(%__MODULE__{}) :: list(middleware())
def middleware(redux), do: redux.middleware
@doc """
Create a middleware for logging actions.
"""
@spec logger_middleware :: middleware()
def logger_middleware do
fn action, state, next ->
IO.puts("[Redux] Action: #{inspect(action)}")
IO.puts("[Redux] Before: #{inspect(state)}")
new_state = next.(action)
IO.puts("[Redux] After: #{inspect(new_state)}")
new_state
end
end
@doc """
Create a middleware for validating actions.
"""
@spec validation_middleware((action() -> boolean())) :: middleware()
def validation_middleware(validator) do
fn action, state, next ->
if validator.(action) do
next.(action)
else
IO.puts("[Redux] Invalid action: #{inspect(action)}")
state
end
end
end
@doc """
Subscribe to state changes.
See `Phoenix.SessionProcess.Redux.Subscription` for details.
## Examples
# Subscribe to all changes
redux = Redux.subscribe(redux, fn state ->
IO.inspect(state, label: "State changed")
end)
# Subscribe with selector
redux = Redux.subscribe(redux, fn state -> state.user end, fn user ->
IO.inspect(user, label: "User changed")
end)
"""
@spec subscribe(%__MODULE__{}, function()) :: %__MODULE__{}
def subscribe(redux, callback) when is_function(callback, 1) do
alias Phoenix.SessionProcess.Redux.Subscription
{redux, _sub_id} = Subscription.subscribe_to_struct(redux, nil, callback)
redux
end
@spec subscribe(%__MODULE__{}, function() | map(), function()) :: %__MODULE__{}
def subscribe(redux, selector, callback) when is_function(callback, 1) do
alias Phoenix.SessionProcess.Redux.Subscription
{redux, _sub_id} = Subscription.subscribe_to_struct(redux, selector, callback)
redux
end
@doc """
Unsubscribe from state changes.
## Examples
{redux, sub_id} = Redux.Subscription.subscribe_to_struct(redux, nil, callback)
redux = Redux.unsubscribe(redux, sub_id)
"""
@spec unsubscribe(%__MODULE__{}, reference()) :: %__MODULE__{}
def unsubscribe(redux, subscription_id) do
alias Phoenix.SessionProcess.Redux.Subscription
Subscription.unsubscribe_from_struct(redux, subscription_id)
end
@doc """
Notify all subscriptions of state changes.
This is automatically called by `dispatch/2` and `dispatch/3`,
but can be called manually if needed.
## Examples
redux = Redux.notify_subscriptions(redux)
"""
@spec notify_subscriptions(%__MODULE__{}) :: %__MODULE__{}
def notify_subscriptions(redux) do
alias Phoenix.SessionProcess.Redux.Subscription
Subscription.notify_all_struct(redux)
end
@doc """
Enable PubSub broadcasting for a Redux store.
## Examples
redux = Redux.init_state(%{count: 0})
redux = Redux.enable_pubsub(redux, MyApp.PubSub, "session:123")
"""
@spec enable_pubsub(%__MODULE__{}, module(), String.t()) :: %__MODULE__{}
def enable_pubsub(redux, pubsub_module, topic) do
%{redux | pubsub: pubsub_module, pubsub_topic: topic}
end
@doc """
Disable PubSub broadcasting for a Redux store.
## Examples
redux = Redux.disable_pubsub(redux)
"""
@spec disable_pubsub(%__MODULE__{}) :: %__MODULE__{}
def disable_pubsub(redux) do
%{redux | pubsub: nil, pubsub_topic: nil}
end
@doc """
Manually broadcast a state change via PubSub.
Usually called automatically by dispatch, but can be called manually if needed.
## Examples
Redux.broadcast_state_change(redux, {:custom_action})
"""
@spec broadcast_state_change(%__MODULE__{}, action()) :: :ok
def broadcast_state_change(%{pubsub: nil}, _action), do: :ok
def broadcast_state_change(%{pubsub: pubsub, pubsub_topic: topic} = redux, action) do
message = %{
action: action,
state: redux.current_state,
timestamp: System.system_time(:millisecond)
}
Phoenix.PubSub.broadcast(pubsub, topic, {:redux_state_change, message})
end
@doc """
Subscribe to PubSub broadcasts from other Redux stores.
This allows you to listen to state changes from other processes or nodes.
Returns a function to unsubscribe.
## Examples
# In a LiveView process
unsubscribe = Redux.subscribe_to_broadcasts(
MyApp.PubSub,
"session:123",
fn message ->
# Handle remote state change
send(self(), {:remote_state_change, message})
end
)
# Later, unsubscribe
unsubscribe.()
"""
@spec subscribe_to_broadcasts(module(), String.t(), (map() -> any())) :: (-> :ok)
def subscribe_to_broadcasts(pubsub_module, topic, callback) do
Phoenix.PubSub.subscribe(pubsub_module, topic)
# Store callback in process dictionary
callbacks = Process.get(:redux_pubsub_callbacks, %{})
ref = make_ref()
Process.put(:redux_pubsub_callbacks, Map.put(callbacks, ref, callback))
# Start message handler if not already started
unless Process.get(:redux_pubsub_handler_started) do
spawn_link(fn -> pubsub_message_handler() end)
Process.put(:redux_pubsub_handler_started, true)
end
# Return unsubscribe function
fn ->
callbacks = Process.get(:redux_pubsub_callbacks, %{})
Process.put(:redux_pubsub_callbacks, Map.delete(callbacks, ref))
:ok
end
end
# Private function to handle PubSub messages
defp pubsub_message_handler do
receive do
{:redux_state_change, message} ->
callbacks = Process.get(:redux_pubsub_callbacks, %{})
Enum.each(callbacks, fn {_ref, callback} ->
try do
callback.(message)
rescue
error ->
require Logger
Logger.error(
"Redux PubSub callback error: #{inspect(error)}\n" <>
Exception.format_stacktrace(__STACKTRACE__)
)
end
end)
pubsub_message_handler()
_ ->
pubsub_message_handler()
end
end
@doc """
Default reducer that returns state unchanged.
Can be overridden by implementing this function in your module.
"""
@spec reducer(state(), action()) :: state()
def reducer(state, _action) do
state
end
end