Usually, working with Redux architecture feels very verbose and time-consuming. If you want to patch something very fast, you would feel that your hands are tightened. There should be a simple way to code using Redux patterns. It should be simple as writing a class method. Hence Redux Controllers
You will find an easy way to create Redux stores and mutation methods that can be easily integrated with React/ React Native/ Angular codebase. You will also be able to write separate tests for Redux Controllers on NodeJs using Mocha.
Features
- "Complexity" of Reducers/ Actions/ Action Creators/ Dispatchers taken of
- Out of the box immutability management (inbuilt Immer)
- Support for Asynchronous Commits
- Helper functions to efficiently bind store to component
- Out of the box persistent
- Ability to use existing reducers and middleware along with Redux Controllers
- Typescript Support
- Application testing Framework
- Watchers -> Actions and State
- Inbuilt Caching
- Install Redux Controllers
using npm
npm i redux-controllers -s
npm i rxjs -s
or using yarn
yarn add redux-controllers
yarn add rxjs
- Create your first Redux controllers
Redux Controllers mainly consists of two parts
- Redux Store initialization function
- Controllers
controllers/counter/counter.controller.ts
import { RootState } from "../store";
import { ReduxController, ReduxControllerBase, ReduxAsyncAction, CommitFunction, ReduxAction, AutoUnsubscribe, ReduxEffect, ReduxWatch } from "redux-controllers";
export interface CounterState {
counter: number,
}
@ReduxController((rootState: RootState) => rootState.counterState)
export class CounterController extends ReduxControllerBase<CounterState, RootState> {
defaultState = {
counter: 0
}
}
controllers/store.ts
import { CounterState, CounterController } from "./counter/counter.controller";
import { Reducer, combineReducers } from "redux";
import { ReduxControllerRegistry } from "redux-controllers";
import { AsyncStorage } from "react-native"
export interface RootState {
counterState: CounterState,
}
export function initStore() {
ReduxControllerRegistry.init([
CounterController,
], {
environment: 'REACT_NATIVE',
persistance: {
active: true,
throttle: 200,
asyncStorageRef: AsyncStorage
}
});
ReduxControllerRegistry.load();
}
- Start Redux Controller in the beginning of the app
import * as React from 'react';
import { Component } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native';
import { initStore, RootState } from './controllers/store';
// Initiate Redux Stores
initStore();
export default class App extends Component<any, any> {
render() {
return (
<View style={{}}>
<TouchableOpacity onPress={this.login} style={styles.button}>
<Text style={styles.buttonText}>Login</Text>
</TouchableOpacity>
</View>
);
}
}
Things to notice
- Controllers have a reference to
rootstate
. This is for typings to work properly - Controllers themselves state which part of the Redux State they are controlling(mutating).
- Default state is provided as a property
ReduxControllerRegistry
accepts an array ofReduxControllers
as the first parameter and accepts configuration option as the second parameter. The options should follow one of the interfaces based on the environmentReduxControllerOptions_web | ReduxControllerOptions_reactNative | ReduxControllerOptions_node
- The interface definitions can be found here
Hola! You have created your first Redux controller 😃
controllers/counter/counter.controller.ts
import { RootState } from "../store";
import { ReduxController, ReduxControllerBase, ReduxAsyncAction, CommitFunction, ReduxAction, AutoUnsubscribe, ReduxEffect, ReduxWatch } from "redux-controllers";
export interface CounterState {
counter: number,
}
@ReduxController((rootState: RootState) => rootState.counterState)
export class CounterController extends ReduxControllerBase<CounterState, RootState> {
defaultState = {
counter: 0
}
@ReduxAction()
increaseCounter(increaseBy?: number) {
this.state.counter++;
}
}
Things to notice
- All ReduxAction() functions take 1st parameter as the payload
increase
method is decorated by@ReduxAction()
. This decorator converts the method into a Redux Action and creates a reducer in the background- State is mutable directly.
you don't need to do
state = Object.assign({},counter:state.counter++);
- You don't need to do return any value.
import * as React from 'react';
import { Component } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native';
import { initStore, RootState } from './controllers/store';
import { GetController, ReduxConnect, Connect } from 'redux-controllers';
import { CounterController } from './controllers/counter/counter.controller';
import Counter, { CounterConnectedProps } from './counter.component';
// Initiate Redux Stores
initStore();
export default class App extends Component<AppProps, AppState> {
increment = () => GetController(CounterController).increaseCounter();
counterConnector = (state: RootState): CounterConnectedProps => ({
counter: state.counterState.counter,
})
render() {
return (
<View style={styles.container}>
<Connect connector={this.counterConnector}>
<Counter />
</Connect>
<TouchableOpacity onPress={this.increment} style={styles.button}>
<Text style={styles.buttonText}>Increase ++ </Text>
</TouchableOpacity>
</View>
);
}
}
export interface AppProps {
counter: number;
}
export interface AppState {
counter: number;
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'stretch',
paddingTop: 60,
},
counterText: {
flex: 1,
color: '#222',
fontFamily: 'Arial',
fontSize: 20,
textAlign: 'left',
},
button: {
backgroundColor: '#333',
padding: 10,
justifyContent: 'center',
alignItems: 'center',
},
buttonText: {
color: '#fff',
fontFamily: 'Arial',
fontSize: 16,
textAlign: 'center',
}
});
Things to notice
- To get the instance of the controller you can use
GetController
function. - There are multiple ways of reading the state
- Read State Directly
- Eg: `GetController(CounterController).state.counter
- Subscribe to state
- Eg:
GetController(CounterController).subscribeTo(state=>state.counter).subscribe(counter=>{ ... })
- For React and React Native Projects, there are two helper functions that are available to connect them directly
-
Using @ReduxConnect Decorator
@ReduxConnect<RootState, any>((rootState) => ({ counter: rootState.counterState.counter, }))
-
@ReduxConnect
decorater will automatically push state changes to theprop
of components -
To get the typings, you should provide interface of
RootState
and the interface the properties of the component being decorated -
The object returned by the function is pushed as properties whenever any changes happen to the mapped states.
-
-
Using HOC
Connect
inside templateConnect
accepts a propertyconnector
which is a state mapping function
-
- Read State Directly
- Redux Async Actions
- Redux Watch
- Redux Effects
- Connecting Existing Middleware
- Configuring Persistence
- Enabling/Disabling Redux Debugger
- Accessing Root State within controllers
- Omitting sub states from persisting
- Named/Unnamed Actions
- Customized Commit Messages
- Providers
- Resetting SubStates and RootState
- Writing Helper Functions in Redux Controller
- State based watchers
Todo: Write a doc for Advanced Features and Configurations
Redux Async Actions helps you to work with asynchronous state changes
Eg:
counter.controller.ts
import { RootState } from "../store";
import { ReduxController, ReduxControllerBase, ReduxAsyncAction, CommitFunction, ReduxAction, AutoUnsubscribe, ReduxEffect, ReduxWatch } from "redux-controllers";
export interface CounterState {
counter: number,
}
@ReduxController((rootState: RootState) => rootState.counterState)
export class CounterController extends ReduxControllerBase<CounterState, RootState> {
defaultState = {
counter: 0
}
@ReduxAsyncAction()
async loadCounterFromBackend() {
const counterValue: number = await new Promise(resolve => {
setTimeout(() => {
resolve(100);
}, 2000);
});
this.commit(state => {
state.counter = counterValue;
});
}
}
app.tsx
...
export default class App extends Component<AppProps, AppState> {
loadRemoteState = () => GetController(CounterController).loadCounterFromBackend();
counterConnector = (state: RootState): CounterConnectedProps => ({
counter: state.counterState.counter,
})
render() {
return (
<View style={styles.container}>
<Connect connector={this.counterConnector}>
<Counter />
</Connect>
<TouchableOpacity onPress={this.loadRemoteState} style={styles.button}>
<Text style={styles.buttonText}>Load State </Text>
</TouchableOpacity>
</View>
);
}
}
...
Things to notice
- Once an asynchronous action is completed,
this.commit
is called to make the state changes - There will be two Redux Action Fired
- One when the method is called
- One when the commit happens
- The method is a promise and the promise gets resolved when the commit happens. This helps you to maintain local state of the action within the component
Redux Watch helps you to watch a field/ path/ computed value and trigger a function based on on it
@ReduxWatch((rootState: RootState) => ({
token: rootState.user.accessToken,
}))
watchUserToken({token}: { token: string }) {
// Do something
// Eg: Configure Service SDK
}
Things to notice
@ReduxWatch
decorator takes in a state mapping function as the only parameter- The decorated method will only fire if the returned object's key value has changed
- The mapping function must always be an object. It should not be a primitive value
Redux Effect helps you to trigger a function as a side effect when a Redux Action is dispatched.
@ReduxEffect('LOGIN')
async onUserLoggedIn({user}: { user: any }) {
// Do something
// Eg: Configure Service SDK
}
Things to notice
@ReduxEffect
decorator takes in a the action name to watch for as the only parameter- The decorated method will only fire if the mentioned action is dispatched
- The method decorated by
@ReduxEffect
must always be a promise.
State providers helps you to easily manage resources and entities in the state with caching
yyy.controller.ts
defaultState = {
todoList: ProvidedState([]),
todoMap: {},
timeBasedList: ProvidedTimeBasedState([])
}
providers = {
state: {
todoList: Provider(async () => {
await new Promise((res, rej) => {
setTimeout(() => {
res();
}, 2000);
});
return dummyTodos;
}, 2000),
todoMap: ProvideKey(async (key) => {
await new Promise((res, rej) => {
setTimeout(() => {
res();
}, 2000);
});
return {
id: key,
text: key + "Todo 1",
isCompleted: false
};
}),
timeBasedList: ProvideTimeRangeBasedData<Todo[]>((range) => this.loadTodosInTimeRange(range), true)
},
cacheTimeout: 0
}
yyy.component.tsx
// load resources | respects cache
await GetController(YYYController).load(state=>state.todoList);
// load resource | force refresh
await GetController(YYYController).load(state=>state.todoList,true);
// Loading Key
await GetController(YYYController).load(state=>state.todoMap.xyz);
// Loading Resources belonging to a date range
await GetController(YYYController).load(state=>state.timeBasedList,{ from: new Date().getTime(), to: new Date().getTime() - 100000 });
Todo: Write a doc for Advanced Features and Configurations
- Calendar Events
- Paginated List
- UI Elements State
Todo: Write a doc for Complex Store Examples
Todo: Write Doc for Simple Testing Framework
Todo: Fill in contributors
Run Process chrome://inspect/#devices Install Remove Dev npm install --save-dev remotedev-server or npm install -g remotedev-server Run to start dev server remotedev --hostname=localhost --port=1234
Pass in options.devToolsOptions to init function
devToolsOptions: {
host: '127.0.0.1',
port: 1234
}
Add Helper Notes with https://stackoverflow.com/questions/43177855/how-to-create-a-deep-proxy for state and commit function
- Add
.set(state=>state.foo,{value});
method to set a path - Add Provide Crud | Provide Resource
ProvideCrud({
load:()=>{},
edit:()=>{}
})
- Add Provide Resources
ProvideResources({
key:'id'
load:()=>{},
edit:()=>{},
delete:()=>{}
}),
- make payload optional for redux action and async action
- expose this.rootState