Sitka is a small but powerful framework for state management. It organizes your application into modules, each managing a logical area of state. It builds on top of Redux and Redux Saga, giving you all the benefits of those tools without the boilerplate.
Lets examine a simple counter application, contrasting the differences between a traditional Redux / Redux Saga implementation and a Sitka implementation.
A simple counter application
For the purposes of illustration, we'll work with a simple counter app built using TypeScript, whose state exists in a Redux store, and uses Redux Saga to coordinate its logic.
In simple applications, it may be enough to change state by calling an action directly consumed by a Redux reducer. However, in larger apps, Sagas offer a useful mechanism for triggering compound operations around a single logical action such as "increment counter". For example, the increment action might require permissions checks, logging, and even asynchronous access to a secondary data store before finally calling a reducer to change state.
The counter app discussed here is a simplified model of a much larger application, similar to others we’ve created at Olio Apps.
The traditional way
Building the counter app using TypeScript, Redux, and Redux Saga will require:
- the counter state in Redux
- an interface for the increment action creator
- an action creator that uses the interface
- a listener for this action in the Sagas index, routing to a Saga
- a selector to get the current counter state from Redux
- a Saga, which handles the action's payload as well as any other application side-effects, and setting a new value in Redux state via reducer, using a second Redux action creator
- an interface for this reducer-facing action creator
- an action for the reducer-facing action creator
- a reducer listening for the value-setting action creator
- registration of the reducer with the root reducer
Below we have a fully realized implementation of these components:
// 0. create the shape of the managed part of state
interface Counter {
value: number
}
const defaultCounterState: Counter = { value: 0 }
interface AppState {
counter: Counter
}
const defaultAppState: AppState = {
counter: defaultCounterState,
}
// 1. interface for action creator to handle increment
interface HandleIncrementAction {
type: "HANDLE_INCREMENT",
}
// 2. action creator to handle increment
const handleIncrement = (): HandleIncrementAction => ({
type: "HANDLE_INCREMENT"
})
// 3. a listener in the root saga
default function* root(): {} {
yield [
takeEvery("HANDLE_INCREMENT", handleIncrement)
]
}
// 4. a selector function to get a specific part of Redux state
function selectCounter(state: AppState): Counter {
return state.counter
}
// 5. a saga function to handle side effects and/or payload
function* handleIncrement(action: HandleIncrementAction): {} {
// uses the selector defined in step 4
const counter = yield select(selectCounter)
const newValue = counter.value + 1
// uses the action/interface defined in steps 6 and 7
yield put(actions.setCounter(newCounter))
}
// 6. interface for action creator to set new value in state
interface SetCounter {
type: "SET_COUNTER"
value: number
}
// 7. action creator to set new value in state
const setCounter = (value: number) => ({
type: "SET_COUNTER",
value,
})
// 8. a reducer listening for the action called in step 5
function counter(
state: Counter = defaultCounterState,
action: SetCounterAction,
): number {
switch (action.type) {
case "SET_COUNTER":
return { ...state, value: action.value }
default:
return state
}
}
// 9. reducer is registered with the root reducer, and a store is created from it
const rootReducer = redux.combineReducers({
counter,
})
const store = createStoreWithMiddleware(rootReducer)
sagaMiddleware.run(root())
TypeScript, Redux and Redux-Saga are great to use together. You gain all the advantages of a strongly-typed codebase, Redux state management, and straightforward control flow of both synchronous and asynchronous operations.
But as you can see from above, an obvious downside of this stack is that typical usage requires a lot of boilerplate! That is where Sitka comes in handy.
Implementing the app using Sitka
Sitka dramatically cuts down the amount of boilerplate. All the code that is needed to accomplish the same counter application above can be written using Sitka like this:
// define the piece of state managed by your module
interface CounterState {
readonly value: number
}
// define the modules under control by Sitka, which includes the module below
interface AppModules {
counter: CounterModule
}
class CounterModule extends SitkaModule<CounterState, AppModules> {
// corresponds to the key in Redux that contains the state managed by this module
public moduleName: string = "counter"
public defaultState: CounterState = {
value: 0,
}
// because this method is public and prefaced by 'handle', it will be wrapped in an action and
// available to client code such as React components
public *handleIncrement(): {} {
const counter: CounterState = yield select(this.getCounter)
const newValue = counter.value + 1
yield put(this.setState({ value: newValue }))
}
private getCounter(state: AppState): CounterState {
return state.counter
}
}
// instantiate an instance of Sitka, and give it the modules
const sitka = new Sitka<AppModules>()
sitka.register([new CounterModule()])
A full counter application can be found here on Github.
A moduleName
and defaultState
are set within the class, and a single generator function *handleCounter
is defined, which simply increments the counter by 1.
The public
and private
keywords are used to show which class methods are callable from outside of the class itself. For more about this feature of TypeScript, see their handbook about classes. In this case, getCounter
is marked private
so that only CounterModule
has access to its own state.
This is much less code than the first example. This makes it more maintainable and easier to reason about. Overall, this leaves you with a cleaner codebase.
In your presentational component, this is how you might call *handleIncrement
:
const { counter } = sitka.getModules()
// this fires a Redux action that
// Sitka uses to increment counter
<button onClick={counter.handleIncrement}>INCREMENT</button>
The handleIncrement
generator defined in your module is wrapped in an Action that is created under the hood, because its name starts with handle
.
What’s happening under the hood
Sitka spares you from writing boilerplate by autogenerating code. It generates:
- An action wrapping each generator function
- A reducer backing the setState method
- A section of the redux state tree, defined by the sitka class attribute
moduleName
- in this casecounter
.
When should you use Sitka
We recommend using Sitka if you intend to build a larger application using typescript and redux. Sitka keeps your code strongly typed and organized, while reducing the boilerplate burden of that redux and typescript can add out of the box. Less typing = faster development time.
Upgrading existing applications
Though you can build your application from the ground up using Sitka, it's also designed to be incrementally added to an existing Redux application. See an example of adding Sitka into a project, and an example of usage using React-Redux's connect
function to connect your component to a Sitka-powered Redux store
.
Olio Apps is using Sitka
We are using Sitka in live projects, and it's delightful to work with. We get to take advantage of all the benefits of a strongly-typed codebase, while also enjoying writing a small fraction of the code we needed before using Sitka. We at Olio Apps hope you try out Sitka to manage state in your next project, or even to implement a new feature in your existing project.