Skip to content

nathalia-rus/to-do-typesafe-typescript-redux-observable

Repository files navigation

Todo App with Typesafe Redux, Redux-Obervables, Typescript

App remade from scratch following @piotrwitek documentation in order to familiarize myself with Typescript, Redux, Reduc-Observables, RxJS, Typesafe-Actions. See docs: https://github.com/piotrwitek/typesafe-actions

INDEX

1. Structure

Components

< AddTodoForm />, < TodoList />, < TodoListActions />, < TodoListItem />, < TodosView />

NB/ Atomic not implemented on this project as not part of the focus.

Pages

Simply < Home /> which renders the < TodosView />.

Redux

REDUX
   
|--- modules
     |---- todos
          |------- actions.ts
          |------- apics.ts
          |------- reducer.ts
          |------- selector.ts
          |------- types.d.ts
          
          
|--- services
    |------- index.ts
    |------- todos-api-client.ts
    |------- type.d.ts
    
    
|--- store
    |------- index.ts
    |------- root-action.ts
    |------- root-epic.ts
    |------- root-reducer.ts
    |------- types.d.ts
    |------- utils.ts

Store

In my opinion, it is better to start with creating all of the folder's files (in .js extension initially so it doesn't throw an error). Start coding even if the imports (ex: actions) are not created yet. So the structure is there, as the Types declared flow down and are used in the final ./store/index main reducer. Crucially: RootAction, RootState, RootEpic, services. Plan in advance the architecture of this - so that it becomes such a bliss as you get going a all is planned and the order in which you're doing things become intuitive and efficient.

Note: Services (with uppercase S) from the modules/services can be created then too. Your choice.

        index.ts 
        
        // ./store folder 

Main store configuration. Assembles all actions, all reducers, all services used throughout the app, initializes the state, creates the store and handles the epicMiddleware.

This is where createEpicMiddleware makes use of RootAction, RootState, Services custom modules we created in ./root-action.ts etc are again made great use of.

Right so:

import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';
// import from module created in types.d.ts gets imported
import { RootAction, RootState, Services } from 'typesafe-actions';
// utils
import { composeEnhancers } from './utils';
// material for the store:
import rootReducer from './root-reducer';
// import services from services
import services from '../services';
import RootEpic from './root-epic';

// create epicMiddleware

// the createEpicMiddleware is constituted of the types of RootAction,
// RoosState etc I created in the module at types.d.ts file

export const epicMiddleware = createEpicMiddleware<
  RootAction,
  RootAction,
  RootState,
  Services
>({
  dependencies: services,
});

// onfigure middlewares
const middlewares = [epicMiddleware];

// compose enhancers here
const enhancer = composeEnhancers(applyMiddleware(...middlewares));

// rehydrate app state on app start: the initial state.
const initialState = {};

// finally, create store: created with the reducer, the initial state, and the enhancers,
// hence the rootReducer !

const store = createStore(rootReducer, initialState, enhancer);

// cf new version of redux-observable: now you have to run epic middleware right AFTER creating the store.
epicMiddleware.run(RootEpic);

export default store;

onto the rest of the files within the store folder:

        root-action.ts
        
        // ./store folder 

This is where you import * as ${feature}Actions from ../modules.todos/actions . If more than 1 feature, import them all and combine them there from this file altogether.

Right so:

import { routerActions } from 'react-router-redux';
import * as todoActions from '../modules/todos/actions';

export default {
  router: routerActions,
  todos: todoActions,
};

Onto the next file, root-epic :

        root-epic.ts
        
        // ./store folder 

This is where we make use of combineEpicsfrom "redux-obervables". Same aslast file -but this time, comibning all actions from our epics. Right so:

import { combineEpics } from 'redux-observable';

import * as todosEpics from '../modules/todos/epics';

export default combineEpics(...Object.values(todosEpics));

Onto the file :

        root-reducer.ts
        
        // ./store folder 

This is where we make use of combineReducersfrom "redux-observables".

import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
import todosReducer from '../modules/todos/reducer';

const rootReducer = combineReducers({
  router: routerReducer,
  todos: todosReducer,
});

export default rootReducer;

Now, onto the file where we get the type definitions needed regarding the store folder :

   types.d.ts
   
   // ./store folder 

Namely, the types of Store, RootState, RootAction, and Types of the RootAction's interface.

import { StateType, ActionType } from 'typesafe-actions';

declare module 'typesafe-actions' {
 // store I export in store/index
 export type Store = StateType<typeof import('./index').default>;
 // root reducer I export in root-reducer which combines all my reducers.
 export type RootState = StateType<typeof import('./root-reducer').default>;
 // root action I export in root-action which unites all of my actions.
 export type RootAction = ActionType<typeof import('./root-action').default>;
 // types of actions' interface.
 interface Types {
   RootAction: RootAction;
 }
}

and finally, the utils file which contains the composeEnhancers (for redux devtools extension) :

   utils.ts
   
   // ./store folder 

which is basically:

import { compose } from 'redux';

export const composeEnhancers =
  (process.env.NODE_ENV === 'development' &&
    window &&
    window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
  compose;

modules/todos + modules/services

Coding from scratch? Let's start with the order that it would make the most sense to start with. In my opinion:

  1. types.d.ts from ./todos

  2. actions.ts from ./todos

  3. selector.ts from ./todos

  4. types.d.ts from ./services

  5. todos-api-clients.ts from ./services

  6. index.ts from ./services

  7. epics.ts

  8. reducer.ts

    types.d.ts
    
    // ./modules/todos folder 
    

This is where the types of the folder's features are defined. In our case, it is a to do app - so it's simply the Todo.

! important. Again, in types.d.ts, like in any other files of that nature, we declare a module. Imported outside as ".." from "nameofmodule".

Right so:

// declare module here
// so to import elsewhere as import { myModel} from "MyModels"

declare module 'myModels' {
  export type Todo = {
    id: string;
    title: string;
  };
}

Model which will be used a lot, amongst other, in the actions, ./modules/services folder's files.

Next: onto actions.

   actions.ts
   
   // ./modules/todos folder 

Important bit: the use of "typesafe-actions" 's lib action creators' helpers.

import { createAsyncAction, createStandardAction } from 'typesafe-actions';

so that:

  • create standard actions (not an async or anything fancy):
export const addTodo = createStandardAction('ADD_TODO').map(
  ({ title }: { title: string }): { payload: Todo } => ({
    payload: {
      title: title,
      id: cuid(),
    },
  })
);

export const removeTodo = createStandardAction('REMOVE_TODO')<string>();
  • create async functions -with automatic helpers that will come in other files regarding the handling of request, success, failure steps:
export const loadTodosAsync = createAsyncAction(
  'LOAD_TODOS_REQUEST',
  'LOAD_TODOS_SUCCESS',
  'LOAD_TODOS_FAILURE'
)<undefined, Todo[], string>();

// create async fn to save snapshot
export const saveTodosAsync = createAsyncAction(
  'SAVE_TODOS_REQUEST',
  'SAVE_TODOS_SUCCESS',
  'SAVE_TODOS_FAILURE'
)<undefined, undefined, string>();

voilà!

Next file: selector. Just so it becomes clear of which big chunks of data will be put in the reducer - for bigger clarity of thought ( my opinion ).

   selector.ts
   
   // ./modules/todos folder 
// either already created, either not:
// but need here to import the state of the todo from the reducer.

import { TodosState } from './reducer';

export const getTodos = (state: TodosState) => state.todos;

Ok, now let's go to services folder, and then come back to the actions' one.

   types.d.ts
   
   // ./modules/services folder 

There, you export the type Services -which remember is used in the store/indexfile (main store). Right so:

import {} from 'typesafe-actions';

declare module 'typesafe-actions' {
  export type Services = typeof import('./index').default;
}

even if nothing is in ./index just yet.

That's why it could also equally have been created while we were setting up the store folder.

Then, onto the actual functions :

   todos-api-client.ts
   
   // ./modules/services folder 

This is where we import the Todo model, as we will retrieve the array of todos and initialize if need be -our case.

Right so (specific to our app basic initial setup for demo purposes):

import { Todo } from 'myModels';

let todos: Todo[] = [
  {
    id: '0',
    title: 'This is the first to do coming from api client.',
  },
];

and we add the services api actions -here, simply getting our initial hardcoded data which we fake takes time to load with 500 time out:

export function loadSnapshot(): Promise<Todo[]> {
 return new Promise((resolve, reject) => {
   setTimeout(() => {
     resolve(todos);
   }, 500);
 });
}

We are now ready to export it all from the index.

    index.ts
   
   // ./modules/services folder 

Right so:

NB - you can categorize the different types of services so it doesn't get messy overtime and all is clear. So you have this object:

import * as todos from './todos-api-client';

export default {
 api: {
   todos,
 },
};

So that in epics.ts, you can access './todos-api-client' functions neatly through api.todos.nameOfFunction()

Which is the next file:

    epics.ts
   
   // ./modules/todos folder 

This is where most of RxJS "magic" is used, along with redux-observable.

export const loadTodosEpic: Epic<
  RootAction,
  RootAction,
  RootState,
  Services
> = (action$, state$, { api }) =>

Type returned is Epic (observable), which is constituted of RootAction (the actions), the RootState and Services, and takes the action passed as an argument along side the state and any services dependencies -in our case, api.

So that :

export const loadTodosEpic: Epic<
  RootAction,
  RootAction,
  RootState,
  Services
> = (action$, state$, { api }) =>
  action$.pipe(
    filter(isActionOf(loadTodosAsync.request)),
    switchMap(() =>
      from(api.todos.loadSnapshot()).pipe(
        map(loadTodosAsync.success),
        catchError((message: string) => of(loadTodosAsync.failure(message)))
      )
    )
  );

Now onto the last file of the modules folder -and the rest that had been set up will allow us, with that done, to implement the connection from the component.

    reducer.ts
   
   // ./modules/todos folder 

This is where we import and make use of the models declared in myModels module, as well as the new way to handle actions from typesafe-actions.

import { Todo } from 'myModels';
import { createReducer } from 'typesafe-actions';
import { combineReducers } from 'redux';

We import the actions from ./actions file.

And each mini reducer is like a function that handles an aspect of the store (within that store feature).

// state of whether it is loading:
export const isLoadingTodos = createReducer(false as boolean)
  .handleAction([loadTodosAsync.request], (state, action) => true)
  .handleAction(
    [loadTodosAsync.success, loadTodosAsync.failure],
    (state, action) => false
  );

// todos' state:
export const todos = createReducer([
  {
    id: '0',
    title: 'This is the default Todo that loads on start app',
  },
] as Todo[])
  .handleAction(addTodo, (state, action) => [...state, action.payload])
  .handleAction(removeTodo, (state, action) =>
    state.filter(i => i.id !== action.payload)
  );

To be combined and exported -cd its use in root-reducer's combination of the different possible reducer files from the different features' folder:

const todosReducer = combineReducers({ isLoadingTodos, todos });

export default todosReducer;

And we export the State type of the Todos feature TodosState which naturally is of:

export type TodosState = ReturnType<typeof todosReducer>;

Voilà !

About

to-do-typesafe-typescript-redux-observable

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published