State Management
@tramvai/state
is a library built into tramvai
for managing application state.
Features
- Redux-like state manager
- Built-in library similar to redux-act to reduce boilerplate code
- Contains bindings to
React
components such asuseStore
anduseSelector
- Dynamic initialization of reducers. You can register a reducer at any time or generate a new one
- Point subscriptions to changes in the states of reducers. When data changes, only the affected
useStore
anduseSelector
are recalculated, not everything - Full SSR support
Concepts
- Store - A class that contains the state of all reducers, change subscriptions and is created for each client
- Context - wrapper for the Store which extends Store API and add additional functionality for actions support
- Reducers - entities in which we describe how data will be stored and transformed
- Events - events with which you can change the states of reducers
- Actions - functions that allow you to perform side effects and update data in the store. Similar to
redux-thunk
. Actions is a separate mechanism and is not related directly to@tramvai/state
Recommendations
- Create unique names for reducers, otherwise conflicted reducers will be overwritten by last registered
- You mustn't mutate data in reducers. Otherwise, due to various optimizations, subscribers will not be notified about the changes
- Initialize reducers as early as possible and before using it. Otherwise, when calling
dispatch(userLoadInformation())
, the reducer will not yet track events and will not receive data - Do not store static data in stores. Since this data will be transferred from the server to the client, the data will be duplicated. Better to put in constants
- Break into small reducers. Otherwise, we have a huge reducer that contains a large amount of information and any changes will cause recalculations for a large number of components
- Use Actions to perform side effects and handle complex state changing logic
Quick Start
⌛ Create new reducer:
import { createReducer, createEvent } from '@tramvai/state';
export const increment = createEvent('increment');
export const decrement = createEvent('decrement');
export const CounterStore = createReducer('counter', 0)
.on(increment, (state, payload) => state + 1)
.on(decrement, (state, payload) => state - 1);
⌛ Register reducer in application (global registration, for all pages):
import { provide } from '@tramvai/core';
import { COMBINE_REDUCERS } from '@tramvai/tokens-common';
createApp({
providers: [
provide({
provide: COMBINE_REDUCERS,
useValue: CounterStore,
}),
],
});
⌛ Read and update reducer in component:
import { useStore, useEvents } from '@tramvai/state';
export const Component = () => {
// subscribe to counter reducer state
const counter = useStore(CounterStore);
// bind events to dispatch
const [dispatchIncrement, dispatchDecrement] = useEvents([increment, decrement]);
return (
<>
<h1>Count is: {counter}</h1>
<button onClick={() => dispatchIncrement()}>increment</button>
<button onClick={() => dispatchDecrement()}>decrement</button>
</>
);
};
Store
Store instance is created for each client request and is available in the DI container by the token STORE_TOKEN
.
Interface
type Store = {
dispatch(event);
subscribe(listener);
subscribe(reducer, listener);
getState();
getState(reducer);
};
Usage
STORE_TOKEN
can be used in providers, actions and components.
For example, we can work with Store in commandLineRunner stages:
import { commandLineListTokens } from '@tramvai/core';
import { createEvent, createReducer } from '@tramvai/state';
import { STORE_TOKEN } from '@tramvai/tokens-common';
const provider = {
provide: commandLineListTokens.resolveUserDeps,
useFactory: ({ store }) => {
return function readCounterState() {
const counter = store.getState(CounterStore);
};
},
deps: {
store: STORE_TOKEN,
},
};
getState()
store.getState()
method is used to get the global state, or the state of a particular reducer.
If you want to get the state of all reducers, use getState
without arguments:
const state = store.getState(); // { counter: 0 }
Otherwise pass specific reducer to getState
:
const counter = store.getState(CounterStore); // 0
dispatch()
store.dispatch()
method is used to change state through events (only subscribed to event reducers will be updated):
store.dispatch(increment());
const counter = store.getState(CounterStore); // 1
subscribe()
store.subscribe()
method is used to subscribe to a global state change.
If you want to subscribe for all reducers updates, use subscribe
with one callback argument:
let currentState = store.getState().counter;
const unsubscribe = store.subscribe((nextGlobalState) => {
const nextState = nextGlobalState.counter;
if (currentState !== nextState) {
console.log('counter is changed:', currentState);
currentState = nextState;
}
});
Otherwise pass specific reducer as first argument to subscribe
, and callback as second:
let currentState = store.getState(CounterStore);
const unsubscribe = store.subscribe(CounterStore, (nextState) => {
console.log('counter is changed:', currentState);
currentState = nextState;
});
Reducer
The createReducer
method creates reducer functions that describe the state during initialization and the reaction to state changes.
The working principle and api is built based on Redux reducers and the use interface from redux-act
Interface
createReducer(name, initialState): Reducer
or
createReducer({
name,
initialState,
events,
});
name
- unique name of the reducer. Should not overlap with other reducersinitialState
- default reducer stateevents
- event handlers
Typing
By default, the reducer state type and name are displayed automatically:
import { createReducer } from '@tramvai/state';
const userReducer = createReducer('user', { name: 'anonymous' });
Why do we need typing for the name of the reducer at all? Then this reducer will be more convenient to use together with useSelector
.
If you pass the state type manually, it is desirable to specify the name as the second argument of the generic:
import { createReducer } from '@tramvai/state';
type UserState = { name: string };
const userReducer = createReducer<UserState, 'user'>('user', { name: 'anonymous' });
But, you can simply set the desired type for initialState
:
import { createReducer } from '@tramvai/state';
type UserState = { name?: string };
const userReducer = createReducer('user', {} as UserState);
Events subscription
.on(event, reducer)
When creating a reducer, the .on method becomes available, which allows you to subscribe to events and return a new state.
event
- an event or a string to which the reducer will be subscribedreducer(state, payload)
- a pure function that takes the currentstate
,payload
from the event and must return the new state of the reducer
Example of using the .on
method:
import { createReducer, createEvent } from '@tramvai/state';
export const userLoadInformation = createEvent < { status: string } > 'user load information';
export const userAddInformation = createEvent < { name: string, info: {} } > 'user add information';
const userReducer = createReducer('user', {
info: {},
})
.on(userLoadInformation, (state, info) => ({ info }))
.on(userAddInformation, (state, { name, info }) => ({
...state,
state: {
...state.info,
[name]: info,
},
}));
Automatic creation of events
To automatically create events create the reducer with option object:
import { createReducer } from '@tramvai/state';
const userReducer = createReducer({
name: 'user',
initialState: {
info: {},
},
events: {
userLoadInformation: (state, info: { status: string }) => ({ info }),
userAddInformation: (state, { name, info }: { name: string; info: {} }) => ({
...state,
state: {
...state.info,
[name]: info,
},
}),
},
});
export const { userLoadInformation, userAddInformation } = userReducer.events;
It is imperative to describe the types of the payload
argument in reducers, otherwise type inference for events will not work.
Connecting reducers to the app
For global store registration, for all pages, you can provide COMBINE_REDUCERS
:
import { provide } from '@tramvai/core';
import { COMBINE_REDUCERS } from '@tramvai/tokens-common';
createApp({
providers: [
provide({
provide: COMBINE_REDUCERS,
useValue: CounterStore,
}),
],
});
If you need reducer only for a specific page, you can pass it in the Page or Nested Layout, in reducers
static property.
Event
The createEvent
method creates an event that can be subscribed to in state management
Interface
createEvent(eventName: string, payloadCreator?: PayloadTransformer): EventCreator
eventName
- Unique identifier of the eventpayloadCreator
- an optional function that allows you to combine multiple arguments into one, In cases where the event was called with multiple arguments.
Usage
Creating an event without parameters
import { createEvent } from '@tramvai/state';
const userLoadingInformation = createEvent('user loading information');
userLoadingInformation();
Creating an event with parameters
import { createEvent } from '@tramvai/state';
const userInformation = createEvent<{ age: number; name: string }>('user information');
userInformation({ age: 42, name: 'Tom' });
Create event with payload conversion
import { createEvent } from '@tramvai/state';
const itemPrice = createEvent('user information', (info: string, price: number) => ({
[info]: price,
}));
itemPrice('car', 3000);
Using Events in Actions
In this example we will create an action in which, after loading the information, we create an event and pass it into context.dispatch
:
import { declareAction } from '@tramvai/core';
import { createEvent } from '@tramvai/state';
const userInformation = createEvent<{ age: number; name: string }>('user information');
const action = declareAction({
name: 'userLoadInformation',
async fn() {
const result = await fetch('api/user/information');
this.dispatch(userInformation(result));
},
});
Using Events in React components
The simplest way to dispatch events in React components is to use hook useEvents
:
import { useEvents } from '@tramvai/state';
import { event1, event2, event3 } from './events';
const Component = () => {
// bind single event
const dispatchEvent1 = useEvents(event1);
// bind multiple events
const [dispatchEvent2, dispatchEvent3] = useEvents([event2, event3]);
return (
<div>
<button onClick={() => dispatchEvent1()}>Event 1</button>
<button onClick={() => dispatchEvent2()}>Event 2</button>
<button onClick={() => dispatchEvent3()}>Event 3</button>
</div>
);
};
Another way to do it is by getting the global store instance from di and manually call dispatch function on it:
import { useDi } from '@tramvai/react';
import { useEvents } from '@tramvai/state';
import { STORE_TOKEN } from '@tramvai/tokens-common';
import { event } from './events';
const Component = () => {
// get the global store from di
const store = useDi(STORE_TOKEN);
// bind single event
const dispatchEvent = () => store.dispatch(event());
return (
<div>
<button onClick={() => dispatchEvent}>Event</button>
</div>
);
};
Context
ConsumerContext
instance is created for each client request and is available in the DI container by the token CONTEXT_TOKEN
.
Interface
type ConsumerContext = {
executeAction(action, payload);
dispatch(event);
subscribe(listener);
subscribe(reducer, listener);
getState();
getState(reducer);
};
Usage
CONTEXT_TOKEN
can be used in providers and components.
We recommend to prefer STORE_TOKEN
if you don't need to run actions, for consistency in the codebase
In providers
For example, we can working with Context in commandLineRunner stages:
import { commandLineListTokens } from '@tramvai/core';
import { createEvent, createReducer } from '@tramvai/state';
import { CONTEXT_TOKEN } from '@tramvai/tokens-common';
const provider = {
provide: commandLineListTokens.resolveUserDeps,
useFactory: ({ context }) => {
return function readCounterState() {
const counter = context.getState(CounterStore);
};
},
deps: {
context: CONTEXT_TOKEN,
},
};
In actions
Context methods will be available in action fn
property as this
context:
import { declareAction } from '@tramvai/core';
import { createEvent, createReducer } from '@tramvai/state';
const loadUser = createEvent('load user');
const userReducer = createReducer('user', { name: 'anonymous' });
userReducer.on(loadUser, (state, payload) => payload);
const fetchUserAction = declareAction({
name: 'fetchUser',
async fn() {
const { name } = this.getState(userReducer);
if (name !== 'anonymous') {
return;
}
const response = await this.deps.httpClient.get('/user');
this.dispatch(loadUser(response.payload));
},
deps: {
httpClient: HTTP_CLIENT,
},
});
In components
You can use useConsumerContext React Hook to get the current context from DI.
React Hooks
useStore()
Hook to get the state of a specific reducer.
Features:
- automatically displays the type of state
- re-renders the component only when the reducer is updated
- allows you to create reducers "on the fly"
Interface
useStore(store: Reducer)
store
- store created by createReducer
Usage
Basic example:
import { useStore } from '@tramvai/state';
import { createReducer } from '@tramvai/state';
const userReducer = createReducer('user', { id: '123' });
export const Component = () => {
const { id } = useStore(userReducer);
return <div>{id}</div>;
};
useSelector()
Receiving data from the store in components
Interface
useSelector(stores: [], selector: (state) => any, equalityFn?: (cur, prev) => boolean)
stores
- a list of tokens that the selector will subscribe to. Will affect which store changes will trigger an update in the componentselector
- the selector itself, this is a function that will be called upon initialization and any changes to the stores passed tostores
. The function should return data that can be used in the componentequalityFn
- optional function to change the way of comparing past and new values of a selector
Usage
To get data from a store, you can use a store name, a reference to a store, or an object with an optional store:
'storeName'
storeObject
{ store: storeObject, optional: true }
{ store: 'storeName', optional: true }
You can pass an array of keys, then for correct type inference it is better to use as const
:
useSelector(['fooStoreName', barStoreObject] as const, ({ foo, bar }) => null)
;
import { useSelector } from '@tramvai/state';
export const Component = () => {
const isBrowser = useSelector('media', (state) => state.media.isBrowser);
return <div>{isBrowser ? <span>Browser</span> : <span>Desktop</span>}</div>;
};
Optimizations
In order to reduce the number of component redrawings, after each call to selector
, the return values are checked against those that were before. If the returned selector data has not changed, then the component will not be redrawn.
For this reason, it is better to get small chunks of information in selectors. Then there is less chance that the component will be updated. For example: we need the user's roles
, we write a selector that requests all user data (state) => state.user
and now any changes to the user
reducer will update the component. It is better if we receive only the necessary data (state) => state.user.roles
, in which case the component will be redrawn only when the user's roles
change
useStoreSelector()
A simplified version of the useSelector hook into which only one store can be passed, created via createReducer. It was made to improve the inference of selector types, since useSelector itself cannot do this due to the use of strings, tokens and BaseStore heirs inside string names
Interface
useStoreSelector(store: Reducer, selector: (state) => any)
store
- Store created through createReducerselector
- the selector itself, this is a function that will be called upon initialization and any changes to the store passed tostores
. The function should return data that can be used in the component
Usage
import { useStoreSelector } from '@tramvai/state';
import { createReducer } from '@tramvai/state';
const myStore = createReducer('myStore', { id: '123' });
export const Component = () => {
const id = useStoreSelector((myStore, (state) => state.id)); // The id type will be correctly inferred as "string"
return <div>{id}</div>;
};
Optimizations
The hook is a wrapper over useSelector, so the optimizations are the same. The selector function itself is memoized inside
useActions()
Allows to execute tramvai actions in React components
Interface
useActions(actions: Action): Function
useActions(actions: Action[]): Function[]
actions
- one or an array of tramvai actions
If you pass an array to
useActions
, for typing you need to specifyas const
-useActions([] as const)
Usage
import { useActions } from '@tramvai/state';
import { loadUserAction, getInformationAction, setInformationAction } from './actions';
export const Component = () => {
// if you pass one action, the payload type for loadUser is automatically deduced
const loadUser = useActions(loadUserAction);
// if you pass a list of actions, `as const` is required for correct type inference
const [getInformation, setInformation] = useActions([
getInformationAction,
setInformationAction,
] as const);
return (
<div>
<div onClick={loadUser}>load user</div>
<div onClick={getInformation}>get information</div>
<div onClick={() => setInformation({ user: 1 })}>set information</div>
</div>
);
};
useConsumerContext()
Prefer useActions hook if you need to execute actions only
Interface
useConsumerContext(): ConsumerContext
- will return ConsumerContext
Usage
import { useConsumerContext } from '@tramvai/state';
export const Component = () => {
const context = useConsumerContext();
useEffect(() => {
context.executeAction(anyTramvaiAction, payloadForThisAction);
}, []);
return null;
};
connect
connect
is deprecated for couple of reasons:
connect
forces you to use decorators, which will have to be significantly changed in the future- increases bundle size for 2-3 kb gzip
- unsafe with React concurrent features at the risk of stale props
- React hooks are better, faster and safer way to subsribe to the store
DevTools
To enable Redux DevTools, you need to run:
- Install browser extension: Chrome extension or FireFox extension
- Open the page on
tramvai
and open the extension by clicking on the Redux devtools icon
Possible problems
- For a better user experience, you need to use a separate redux dev tools extension window, not a tab in chrome developer tools, because otherwise the action history is not saved, see issue.
Performance
Since the entire state of the application with all the actions is quite large, there are noticeable lags when working with devtools when using jumps over states/events and when a large number of actions are triggered simultaneously. That's why:
- Use customization techniques to set pickState to reduce the size of data in devtools.
- Increase the value of the latency parameter (passed to connectViaExtension.connect), which essentially debounces sending actions to the extension, see docs
Additional links
Testing
You can find examples how to test reducers or mock store in our complete Testing Guide!