1 unstable release

0.1.0 Oct 7, 2024

#444 in Data structures

MIT license

14KB
127 lines

rust-redux

An attempt to implement Redux for Rust at the React Europe hackathon.

Why

If you like Redux elsewhere you might like it in Rust?

Current state of the project

This is a barebones Redux implementation, and examples/todo_list uses Redux to handle the state of a todo app. Now, middleware is also supported, allowing for enhanced action handling like logging or async operations.

Running To-Do List Example

git clone https://github.com/fanderzon/rust-redux.git
cd rust-redux/examples/todo_list
cargo run

Usage

1. Creating a Store

let mut store = Store::new(root_reducer, State::with_defaults())
    .with_middleware(vec![Arc::new(logger_middleware)]);

This is a standard store creation. Before creating a store, you will need to create a state model of what the store will be storing. We also need a root reducer function that will return an instance of our Redux state model that calls our other individual reducers. Let's start by building our state model.

2. Creating State Model

The State type used in the to-do list example contains all parts of our rust-redux state.

#[derive(Clone, Debug)]
pub struct State {
    pub todos: Vec<Todo>,
    pub visibility_filter: VisibilityFilter,
}

impl State {
    pub fn with_defaults() -> State {
        State {
            todos: Vec::new(),
            visibility_filter: VisibilityFilter::ShowAll,
        }
    }
}

Your state model does not have to be named "State" or have any specific methods of its own. However, it is useful to have a method similar to with_defaults as we'll need to pass some default values into Store::new().

3. Creating a Root Reducer

You can think of the root reducer as the rust-redux substitute for combineReducers in reduxjs. Our root reducer just needs to return our State model where each property in our model is set to the return value of its individual reducer. The root reducer must be of type: fn(&T, U) -> T

fn root_reducer(state: &State, action: Action) -> State {
    State {
        todos: todo_reducer(&state.todos, &action),
        visibility_filter: visibility_reducer(&state.visibility_filter, &action),
    }
}

4. Creating Actions

Actions can be whatever you want them to be. It is only up to your individual reducers to decide how to handle them. The way we decided to build actions was using Rust enums. They allow us to specify a type and a payload without adding extra syntax. Take a look at the Action type created in the to-do example.

#[derive(Clone, Debug)]
pub enum Action {
    Todos(TodoAction),
    Visibility(VisibilityFilter),
}

#[derive(Clone, Debug)]
pub enum TodoAction {
    Add(String),
    Toggle(i16),
    Remove(i16),
}

#[derive(Clone, Debug)]
pub enum VisibilityFilter {
    ShowActive,
    ShowAll,
    ShowCompleted,
}

You will notice that we had to create a generic Action wrapper around our other two action types. This isn't totally necessary as we could have stuffed all our actions inside a single type, but this is much cleaner. It is important to note that you can only pass a single action type into Store.dispatch(). So, if you wish to have multiple action types, you must wrap them up into another type as we have done above, or you can throw all actions into a single type.

5. Creating Reducers

Individual reducers decide how you split up your store's state. Similar to reduxjs, reducers should accept some state and an action. Let's look at an example.

fn todo_reducer(state: &Vec<Todo>, action: &Action) -> Vec<Todo> {
    let mut new_state: Vec<Todo> = state.clone();

    match *action {
        Todos(ref todo_action) => match *todo_action {
            Add(ref title) => {
                let new_id = new_state.len() as i16 + 1;
                new_state.push(Todo::new(new_id, title.to_string()))
            },
            Toggle(todo_id) => {
                if let Some(todo) = get_mut_todo(&mut new_state, todo_id) {
                    todo.completed = !todo.completed;
                }
            },
            Remove(todo_id) => {
                if let Some(todo) = get_mut_todo(&mut new_state, todo_id) {
                    todo.deleted = true;
                }
            }
        },
        _ => (),
    }
    return new_state;
}

Another aspect of reduxjs that we want to adapt is avoiding direct mutation of the store's state. As you can see here, we create a clone of the state that is passed in and mutate the clone's properties instead of the state reference that was passed to the reducer. Mutating the passed-in state in this example isn't actually possible, as our todo_reducer does not accept a mutable reference to Vec<Todo>.

6. Middleware

Middleware allows you to modify or monitor actions before they reach the reducer. In this example, we've added a simple logger middleware that logs every action dispatched and the state after each action.

fn logger_middleware<S, A>(store: Arc<Store<S, A>>, action: A, next: Arc<dyn Fn(A) + Send + Sync>)
where
    S: Clone + Send + 'static + std::fmt::Debug,
    A: Clone + Send + 'static + std::fmt::Debug,
{
    println!("Dispatching action: {:?}", action);
    next(action);
    println!("New state: {:?}", store.get_state());
}

To add middleware to the store, use the with_middleware method when creating the store:

let mut store = Store::new(root_reducer, State::with_defaults())
    .with_middleware(vec![Arc::new(logger_middleware)]);

This allows you to see all actions being dispatched and the resulting state, making debugging easier.

7. Putting it All Together

Now that we have all of our pieces in place, let's subscribe, dispatch, and get our store's state!

Dispatching Actions

use Action::*;
fn main() {
    let mut store = Store::new(reducer, State::with_defaults())
        .with_middleware(vec![Arc::new(logger_middleware)]);
    store.dispatch(Todos(Add("Learn about rust-redux".to_string())));
}

Subscribing

Subscribing allows us to have functions that listen to the store's state directly. Whenever an action is dispatched to the store, these functions are called again with the updated state.

fn update_with_new_state(state: &State) {
    let visibility = &state.visibility_filter;
    println!("Visibility filter updated to:  {:?}", visibility);
}

fn simple_subscribe(state: &State) {
    println!("Nice dispatch!");
}

fn main() {
    let mut store = Store::new(reducer, State::with_defaults())
        .with_middleware(vec![Arc::new(logger_middleware)]);
    store.subscribe(update_with_new_state);
    store.subscribe(simple_subscribe);
}

Getting State

The store's get_state method returns an immutable reference to the current state in the rust-redux store.

fn main() {
    let mut store = Store::new(reducer, State::with_defaults());
    store.dispatch(Todos(Add("Learn about rust-redux".to_string())));
    let my_current_state = store.get_state();
    println!("Current state: {:?}", my_current_state);
}

No runtime deps