├── .github └── workflows │ └── main.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── docs ├── .nojekyll ├── _coverpage.md ├── _sidebar.md ├── advanced.md ├── api-combineReducers.md ├── api-connect.md ├── api-createModel.md ├── api-createStore.md ├── api-devtools.md ├── api-persist.md ├── api-routing.md ├── api-store.md ├── api-thunk.md ├── auth-anonymous.png ├── auth-authenticated.png ├── background.md ├── devtools.png ├── dispatch-intellisense.png ├── index.html ├── overview.md ├── plugin-dispatch.md ├── plugin-effects.md ├── plugin-routing.md ├── quickstart.md ├── recipe-auth.md ├── recipe-fetch.md ├── recipe-firestore.md ├── recipe-routing.md ├── reducer-state-inference.png ├── reducer-state-mismatch.png ├── reducer-state-typing.png ├── strongly-typed-dispatch.png └── strongly-typed-state.png ├── package-lock.json ├── package.json ├── readme.md ├── rollup.config.js ├── server.js ├── src ├── actionType.ts ├── combineReducers.ts ├── compat.ts ├── connect.ts ├── const.ts ├── createModel.ts ├── createStore.ts ├── devtools.ts ├── dispatchPlugin.ts ├── effectsPlugin.ts ├── index.ts ├── persist.ts ├── routingPlugin.ts ├── store.ts └── thunk.ts ├── test ├── compat.js ├── index.html ├── store.js └── types │ ├── combineReducers.ts │ ├── connect.ts │ ├── effects-config.ts │ ├── effects-types.ts │ ├── effects.ts │ ├── index.d.ts │ ├── model.ts │ ├── plugins.ts │ ├── store.ts │ ├── tsconfig.json │ └── tslint.json ├── tsconfig.json └── typings ├── combineReducers.d.ts ├── compat.d.ts ├── connect.d.ts ├── devtools.d.ts ├── index.d.ts ├── model.d.ts ├── modelStore.d.ts ├── models.d.ts ├── persist.d.ts ├── routing.d.ts ├── store.d.ts └── thunk.d.ts /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Compressed Size 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2-beta 12 | with: 13 | fetch-depth: 1 14 | - uses: preactjs/compressed-size-action@v1 15 | with: 16 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 17 | pattern: lib/*.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | This project adheres to No Code of Conduct. We are all adults. We accept anyone's contributions. Nothing else matters. 4 | 5 | For more information please visit the [No Code of Conduct](https://github.com/domgetter/NCoC) homepage. -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/.nojekyll -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | # @captaincodeman/rdx 1.0 6 | 7 | > Like Redux, but Smaller. 8 | 9 | - Boilerplate free state 10 | - Only 1.83 Kb (gzipped) 11 | - Persistence & Hydration 12 | - Async Effects Middleware 13 | - Redux DevTools Supported 14 | - Strongly-Typed (TypeScript) 15 | - Integrated Routing with Params 16 | - Web Component Connect Binding 17 | 18 | [GitHub](https://github.com/CaptainCodeman/rdx/) 19 | [Learn More](overview) -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - Getting started 2 | 3 | - [Overview](overview.md) 4 | - [Quick start](quickstart.md) 5 | - [Advanced](advanced.md) 6 | 7 | - API Reference 8 | 9 | - [combineReducers](api-combineReducers.md) 10 | - [connect](api-connect.md) 11 | - [createModel](api-createModel.md) 12 | - [createStore](api-createStore.md) 13 | - [devtools](api-devtools.md) 14 | - [persist](api-persist.md) 15 | - [routing](api-routing.md) 16 | - [thunk](api-thunk.md) 17 | - [Store](api-store.md) 18 | 19 | - Recipes 20 | 21 | - [Firebase Auth](recipe-auth.md) 22 | - [Firestore Subscriptions](recipe-firestore.md) 23 | - [REST data fetching](recipe-fetch.md) 24 | 25 | - Plugins 26 | 27 | - [dispatch](plugin-dispatch.md) 28 | - [effects](plugin-effects.md) 29 | - [routing](plugin-routing.md) 30 | 31 | - Background 32 | 33 | - [Redux Comparison](background.md) 34 | - [Inspiration](inspiration.md) -------------------------------------------------------------------------------- /docs/advanced.md: -------------------------------------------------------------------------------- 1 | # Advanced Usage 2 | 3 | Assuming you've read through the [quick start](quickstart) you should know the basics of defining models and creating a store. 4 | 5 | Let's expand on what else you need to create an application. We'll assume that the app is split into two main parts, the state will be in the `src/state` folder and the UI will be in the `src/ui` folder. 6 | 7 | ## Store Configuration 8 | 9 | Some of the advanced cases need to know the 'type' of the store, which contains two things - the structure of the `state`, and the properties and methods available on the `dispatch` function. This can be inferred from the configuration passed to the `createStore` function, so it's helpful to create a `const` to make re-using this config easier. As you add additional plugins, such as routing, it's also useful to have a separate place to put the setup for those, too, and it helps to keep the config of the store separated from the instantiation of it. 10 | 11 | ### src/state/config.ts 12 | 13 | Our config object is initially very simple. Don't worry, we'll add more to it later! 14 | 15 | ```ts 16 | import * as models from './models' 17 | 18 | export const config = { models } 19 | ``` 20 | 21 | ### src/state/store.ts 22 | 23 | We now use this in the store setup and also define the type of the store `State` and `Dispatch`. These can be useful if you need to use them as parameters with strict typing enabled, and again, we'll make more use of them later, so just go along with it for now. 24 | 25 | ```ts 26 | import { createStore, StoreState, StoreDispatch } from '@captaincodeman/rdx' 27 | import { config } from './config' 28 | 29 | export const store = createStore(config) 30 | 31 | export interface State extends StoreState {} 32 | export interface Dispatch extends StoreDispatch {} 33 | ``` 34 | 35 | ## Connected UI Components 36 | 37 | While a state store may be the "engine" of our application, what most people think of an app is usually the user interface (UI), that they see. So we want to be able to reflect the state in the UI. 38 | 39 | Rdx is designed for the modern web, not web frameworks of yesteryear. The web platform now has a well supported and inbuilt UI component system in the form of *web components*. There's no need to include any component system in our app bundle which is just additional unnecessary bloat although there are some lightweight libraries such as [lit-element](https://lit-element.polymer-project.org/), which can be useful to make creating web components (or *custom elements*) easier. 40 | 41 | Not _every_ component needs to be connected to the state store, but you are free to connect them as needed. You may be familiar with the patterns in React known as "Higher Order Components" or Smart vs Dumb components. The difference is that some components are aware of the state store, routing, and other concerns, while others are just plain UI widgets. 42 | 43 | Think about your app. You may use UI widgets in the form of a design system / component library such as [Material Web Components](https://material-components.github.io/material-components-web-components/demos/index.html). These are the basic UI pieces, manipulated entirely by setting attributes or properties on them, and they typically communicate any user interaction by raising DOM events. These UI widgets won't know about Rdx, state, routing and so on. You want as much of your UI to be built with these sorts of simple widgets, as it makes developing and testing them easier. 44 | 45 | In your app you will then have some richer components, that render information from the state store and pass the data on to the simple UI widgets. These are "pages" or "views", and they need to be connected to the store and will translate the DOM events that happen as a result of user interactions into actions dispatched to the store. These actions mutate the store state which causes the affected parts of the UI to be re-rendered to reflect the changes. 46 | 47 | Using a combination of the [reselect](https://github.com/reduxjs/reselect) package to memoize state changes1 and a web component library such as [lit-element](https://lit-element.polymer-project.org/) which provides efficient DOM updates _without_ the [overhead of a virtual DOM (vdom)](https://svelte.dev/blog/virtual-dom-is-pure-overhead) approach, we get a very responsive and efficient UI. 48 | 49 | 1 Reselect can _also_ insulate components from changes to the structure of the state store. So using it is good practice, as well as beneficial for performance, and is well under 1Kb when gzipped. 50 | 51 | Connecting an element to the store is easy: Simply inherit from the `connect` mixin which accepts the `store` instance and the base element as parameters. Here's the direct approach for a single component: 52 | 53 | ```ts 54 | import { LitElement, customElement, property, html } from 'lit-element' 55 | import { connect } from '@captaincodeman/rdx' 56 | import { store, State } from '../state' 57 | 58 | @customElement("counter-view") 59 | export class CounterViewElement extends connect(store, LitElement) { 60 | @property({ type: Number }) count = 0 61 | 62 | mapState(state: State) { 63 | return { 64 | count: state.counter 65 | } 66 | } 67 | 68 | render() { 69 | return html` 70 | 71 | ${this.count} 72 | 73 | ` 74 | } 75 | } 76 | ``` 77 | 78 | The `mapState` and `mapEvents` methods can be used to set the properties on the component and listen to DOM events, mapping them back to the store by dispatching actions. The example above utilizes [lit-html](https://lit-html.polymer-project.org/) event listeners to wire up the events directly. See the [connect API](api-connect) for full usage. 79 | 80 | If you have more than a few connected components, you can save some code repetition by creating a connected base component that they inherit from. e.g. 81 | 82 | ### src/ui/connected.ts 83 | 84 | ```ts 85 | import { LitElement } from 'lit-element' 86 | import { connect } from '@captaincodeman/rdx' 87 | import { store } from '../state' 88 | 89 | // define a base class connected up to the state store 90 | export class Connected extends connect(store, LitElement) {} 91 | 92 | // export models and selectors for the state 93 | export * from '../state' 94 | ``` 95 | 96 | ### src/ui/counter.ts 97 | 98 | Our counter element then doesn't need to import so much: 99 | 100 | ```ts 101 | import { customElement, property, html } from 'lit-element' 102 | import { Connected, State } from './connected' 103 | 104 | @customElement("counter-view") 105 | export class CounterElement extends Connected { 106 | // ... 107 | } 108 | ``` 109 | 110 | ## Effects 111 | 112 | Beyond the basic principles of "predictable state" using actions and reducers, one of the key benefits of a state container is being able to hook into the state changes / action dispatches to run additional code, or "side effects". Effectively: "when this happens, I also want to do XYZ". 113 | 114 | These are things that _shouldn't be done in a reducer_ because they are either asynchronous (such as calling a remote API to fetch data) or they depend on some external state (such as the browser `localStorage`) and so wouldn't be deterministic if it was done in the reducer (which needs to be pure functions). 115 | 116 | An example would be when an item in a list is clicked and becomes 'selected'. We might dispatch an action to set the selected state in our store and _then_ we want to fetch the data to render it. The fetch is a remote API call and asynchronous so it can't be done inside the reducer because we can't guarantee whether it will be successful or not, and we don't know how long it will take to execute. 117 | 118 | This is where *effects* come in. An `async` effect function converts these operations into synchronous dispatch calls to mutate the store state in a predictable manner. 119 | 120 | Rdx comes with an inbuilt [effects plugin](plugin-effects), which is more powerful than the basic "thunk" plugin of Redux, and possibly easier to understand and simpler than something like redux-saga (and significantly smaller). 121 | 122 | Effect functions create methods on the dispatch function for the model, just like the reducer functions do. If you have an effect function called `load` on a `todos` model then you can call it using `dispatch.todos.load()`. It will dispatch a `todos/load` action through the store that you can see with DevTools, it just wont mutate the state unless there is also a reducer with the same name. If there _is_ a reducer with the same name, then the reducer payload and the effect payload should match, and the reducer will _always_ be executed _before_ the effect function. At the point that the effect runs, the state has already been mutated. 123 | 124 | ### Effects Factory 125 | 126 | Effects are defined in a similar way to reducers with the exception, that, instead of being declared as an object with reducer functions as properties, the effects property is a _factory method_, that returns an object with the effect function properties. This factory method takes a parameter, that provides access to the entire store state, and dispatch methods. 127 | 128 | This requires a slight change to the store setup, with the introduction of an additional `Store` interface, which adds to the `State` and `Dispatch` already defined: 129 | 130 | ```ts 131 | import { createStore, StoreState, StoreDispatch, ModelStore } from '@captaincodeman/rdx' 132 | import { config } from './config' 133 | 134 | export const store = createStore(config)) 135 | 136 | export interface State extends StoreState {} 137 | export interface Dispatch extends StoreDispatch {} 138 | export interface Store extends ModelStore {} 139 | ``` 140 | 141 | The `Store` interface is necessary to allow the effects defined in any single model of the store to access the full typed state and dispatch methods of the entire store, with all the models combined. We need access to the full store definition inside something that itself makes up only a part of that definition. This is difficult to achieve, which is why the slight TypeScript indirection (magic?!) is necessary to make it work. 142 | 143 | We can now define the `effects` factory function on a model. Let's start with an example where we want to use Rdx to access a list of Todo items. The data will come from a remote REST API and we will have a UI that can display the full list, and a detail page that shows an individual item. Someone could navigate to an individual item from the list page or could land there directly, so when an item is selected, we _might_ have to load it, based on the current state of the store. 144 | 145 | ```ts 146 | import { createModel } from '@captaincodeman/rdx'; 147 | import { Store } from '../store'; 148 | 149 | export interface Todo { 150 | userId: number 151 | id: number 152 | title: string 153 | completed: boolean 154 | } 155 | 156 | interface State { 157 | entities: { [key: number]: Todo } 158 | selected: number 159 | loading: boolean 160 | } 161 | 162 | const endpoint = 'https://jsonplaceholder.typicode.com/' 163 | 164 | export default createModel({ 165 | state: { 166 | entities: {}, 167 | selected: 0, 168 | fetching: false, 169 | }, 170 | 171 | reducers: { 172 | select(state, selected: number) { 173 | return { ...state, selected } 174 | }, 175 | 176 | request(state) { 177 | return { ...state, fetching: true }; 178 | }, 179 | 180 | received(state, todo: Todo) { 181 | return { ...state, 182 | entities: { ...state.entities, 183 | [todo.id]: todo, 184 | }, 185 | fetching: false, 186 | }; 187 | }, 188 | 189 | receivedList(state, todos: Todo[]) { 190 | return { ...state, 191 | entities: todos.reduce((map: any, todo) => { 192 | map[todo.id] = todo 193 | return map 194 | }, {}), 195 | fetching: false, 196 | }; 197 | }, 198 | }, 199 | 200 | effects(store: Store) { 201 | // we want to dispatch actions in multiple effects so we can 202 | // capture the typed dispatch method from the store here instead 203 | // of repeating it 204 | const dispatch = store.getDispatch() 205 | 206 | // this is also a great place to put other variables that may 207 | // need to be accessed from multiple effects such as firestore 208 | // subscriptions 209 | 210 | return { 211 | // select has the same name as the select reducer above which 212 | // means it will run after that reducer has completed but will 213 | // be passed the same payload 214 | async select(selected: number) { 215 | // we need the _current_ state each time this effect is run 216 | // which is why we get it here instead of with the dispatch 217 | // above 218 | const state = store.getState() 219 | 220 | // check if the selected todo entity has already been loaded 221 | if (!state.todos.entities[selected]) { 222 | // if not, notify the store that we are requesting something 223 | // this will set the fetching flag to 'true' which could be 224 | // used to display a loading spinner in the UI 225 | dispatch.todos.request() 226 | 227 | // make the REST API using async await syntax 228 | const resp = await fetch(`${endpoint}todos/${selected}`) 229 | const todo: Todo = await resp.json() 230 | 231 | // tell the store that we received the data 232 | dispatch.todos.received(todo) 233 | 234 | // NOT SHOWN: this could be wrapped in a try / catch block 235 | // to handle exceptions and dispatch the error message to 236 | // the store 237 | } 238 | }, 239 | 240 | async load() { 241 | // load can be called using dispatch.todos.load() and doesn't 242 | // have a matching reducer - it will still dispatch an action 243 | // called `todos/load` which we'll see in the DevTools but it 244 | // doesn't mutate any state (in this model) 245 | dispatch.todos.request() 246 | const resp = await fetch(`${endpoint}todos`) 247 | const todos: Todo[] = await resp.json() 248 | dispatch.todos.receivedList(todos) 249 | } 250 | } 251 | } 252 | }) 253 | ``` 254 | 255 | ### init() Effect 256 | 257 | The example above includes a standalone (non-reducing) `load` effect, which could be called using `dispatch.todos.load()`. That might be something you do based on some UI interaction or due to a route change. Sometimes you may have an effect that you want to kick-start automatically when your app runs. While you _could_ just call the dispatch action yourself anytime after the store is initiated, Rdx will automatically run any effect called `init` when the store is created. This can be an ideal place to initialize external listeners that need to interact with the store or to trigger data fetches as soon as the store is initialized. 258 | 259 | There is also the factory method itself, but remember: At the point the `effects` factory method is called, and until the function returns the effects object, the store has _not_ been fully initialized so the state and dispatch should not be used directly. 260 | 261 | ## Inter-Model Communication 262 | 263 | Because models typically deal with one 'slice' of state in the overall store, it's easy to develop a blinkered view and imagine that there is always a 1:1 mapping between each model's actions (reducers + effects) and that model's state. 264 | 265 | But sometimes you will need one model to respond to actions defined and dispatched by a _different_ model. You could, for instance, have a "todos" model that loads data based on the current authenticated user, with "auth" in it's own model, and when a user signs out you want to clear the list of todo items from memory. There are two ways to approach this. 266 | 267 | ### Using Reducers 268 | 269 | Remember that each reducer function for a model automatically produces the type name for the dispatched action to call it by combining the model name and the function name. So the `signedOut` reducer in an `auth` model will produce an action with the typename `auth/signedOut`. Simple. 270 | 271 | If we want to respond to _another_ models action we can simply create a reducer in our model with a string name matching that full action type name. For example: 272 | 273 | ```ts 274 | export default createModel({ 275 | state: { 276 | entities: {}, 277 | selected: 0, 278 | fetching: false, 279 | }, 280 | 281 | reducers: { 282 | request(state) { 283 | return { ...state, fetching: true }; 284 | }, 285 | 286 | receivedList(state, todos: Todo[]) { 287 | return { ...state, 288 | entities: todos.reduce((map: any, todo) => { map[todo.id] = todo; return map }, {}), 289 | fetching: false, 290 | }; 291 | }, 292 | 293 | // this will be called in the same 'reduce' operation as the auth model itself 294 | // so DevTools will show both the auth.user being set to null AND the todo entities 295 | // being cleared. 296 | // 297 | // NOTE: that the state we're dealing with is always the state of the model we're 298 | // in, the payload is available (if the source reducer defines one) but each model 299 | // can only ever mutate it's _own_ state 300 | 'auth/signedOut'(state) { 301 | return { ...state 302 | entities: {}, 303 | } 304 | } 305 | } 306 | } 307 | ``` 308 | 309 | Now, whenever the `auth/signedOut` action is dispatched, the `todos` model will know to clear it's own state. 310 | 311 | This allows the `todos` model to "hook into" the reducer action defined in the auth model and update it's own state in response to it being dispatched. The `auth` model knows nothing of this integration and the state for both models will be updated together because the root reducer that Rdx creates for you (using `combineReducers` under-the-covers) passes each action dispatched to _all_ the model reducers as part of a single, immediate, 'state update' operation. 312 | 313 | ### Using Effects 314 | 315 | Another way to coordinate work between models is to use an effect in one model to dispatch an action to another. With the auth / todos example, we have a `signedOut` reducer that clears the auth state, so we could add an identically named `signedOut` effect, that will run immediately after the reducer, which could then dispatch an action to clear the todos: 316 | 317 | ```ts 318 | import { createModel } from '@captaincodeman/rdx' 319 | import { Store } from '../store' 320 | import { User } from '../firebase' 321 | 322 | export interface AuthState { 323 | user: User | null 324 | statusKnown: boolean 325 | } 326 | 327 | export default createModel({ 328 | state: { 329 | user: null, 330 | statusKnown: false, 331 | }, 332 | 333 | reducers: { 334 | signedIn(state, user: User) { 335 | return { ...state, user, statusKnown: true } 336 | }, 337 | 338 | signedOut(state) { 339 | return { ...state, user: null, statusKnown: true } 340 | }, 341 | }, 342 | 343 | effects(store: Store) { 344 | const dispatch = store.getDispatch() 345 | 346 | return { 347 | async signedOut() { 348 | // user has been signed out, clear the todos 349 | dispatch.todos.clear() 350 | }, 351 | } 352 | } 353 | }) 354 | ``` 355 | 356 | This adds the knowledge of the inter-model communication to the sender - the todos model wouldn't know anything about auth or _why_ it was being cleared, only that it had a `clear` action that it had to respond to. 357 | 358 | The benefit of this approach is that it's strongly typed and you see the clear, separate, todos action in the DevTools. But the downside is that it's less immediate - there is a state immediately after the user being signed out where the `state.auth.user` is null but the `state.todos.items` is still populated. Although effects run almost immediately, any state update will likely trigger UI changes that could show that inconsistent state - an auth status may show 'anonymous visitor' while the todos are still shown. 359 | 360 | If signing out the user has to dispatch multiple actions, then this causes multiple UI updates in a cascade, which may or may not be noticeable. One place where it can become more obvious is if you combine routing and use an effect, listening to the `route/change` action, to extract parameters and dispatch a `select` type reducer action. Suppose your router is looking at the routing state to chose which view to render. The user clicks a link to navigate to `/todos/123`. At this point the state has changed so any affected UI components will update which causes the `TodoDetail` view to render. It looks for the selected todo to display but the selected ID has not been set yet or is still set to a previously viewed ID. In this case the view could be showing the wrong thing. If you have a more elaborate scenario where you have summary data in a list and extra detail that loads when an item is selected, you could end up showing the title (from the summary) for one item and the detail (from the previously selected) from another. 361 | 362 | The aim of a state container is to have predictable and _consistent_ state, so whenever you can update state in a reducer, this is _preferred_ over updating state through an effect, regardless of which model the effect is in. 363 | 364 | Dispatching multiple actions when there is already an action that the model could respond to is really an _anti-pattern_ and _not recommended_. 365 | 366 | See this excellent presentation ([slides](https://rangle.slides.com/yazanalaboudi/deck)) for a more in-depth explanation of why this approach can be wrong: 367 | 368 | 369 | 370 | ## Routing 371 | 372 | Routing is usually an important part of any client-side app. The current view and parameters for it can be extracted from the browser address bar, and if you click a link within your app, the navigation should be intercepted to update the state. Because it's so common, Rdx provides an inbuilt and integrated router, which adds the routing information to the store. This way it can be used as a trigger within your models and also to drive the views in your UI. 373 | 374 | The routing plugin for Rdx relies on a separate [ultra-tiny (370 byte) router library](https://www.npmjs.com/package/@captaincodeman/router) to perform the route matching. The plugin provides the browser navigation interception and integration of routing information into the store state. 375 | 376 | ### Routing Configuration 377 | 378 | Remember the ridiculously simple config we created earlier? Let's add the routing config to it. Here's an example showing some routes defined and the config that we return expanded to include the routing plugin. 379 | 380 | ```ts 381 | import { routingPlugin } from '@captaincodeman/rdx' 382 | import createMatcher from '@captaincodeman/router' 383 | import * as models from './models' 384 | 385 | const routes = { 386 | '/': 'view-home', 387 | '/todos': 'view-todos', 388 | '/todos/:id': 'view-todo-detail', 389 | '/*': 'view-404', 390 | } 391 | 392 | const matcher = createMatcher(routes) 393 | const routing = routingPlugin(matcher) 394 | 395 | export const config = { models, plugins: { routing } } 396 | ``` 397 | 398 | The default plugin configuration handles parameters in the URL path, so navigating to `/todos/123` would result in the state containing: 399 | 400 | ```json 401 | { 402 | "routing": { 403 | "page": "view-todo-detail", 404 | "params": { 405 | "id": "123" 406 | }, 407 | "queries": {} 408 | } 409 | } 410 | ``` 411 | 412 | The routing plugin intercepts any navigation links within your app ignoring download, external and targeted links plus keyboard-modified clicks to retain normal browser-behavior (e.g. of opening a link in a new tab). It also adds additional dispatch methods to the store to allow you to navigate programmatically using `dispatch.routing.back()` to go back, for instance. 413 | 414 | See the [routing API](api-routing) for more details on the options available. 415 | 416 | ### Router Outlet 417 | 418 | When you have the route information in the state store, you can use it to drive your application views. To do this you will typically have some kind of 'router outlet' which switches the UI shown based on the current route selected. 419 | 420 | There are several ways to do this, see the [routing recipes](recipe-routing) for more alternatives and the nuances between them. Here is a simple router outlet as a web component: 421 | 422 | ```ts 423 | import { RoutingState } from '@captaincodeman/rdx' 424 | import { Connected, State } from '../connected' 425 | 426 | class AppRouterElement extends Connected { 427 | // the page property is the _current_ page / view rendered 428 | private page: string 429 | 430 | // connected mapState method sets the route from the state 431 | mapState(state: State) { 432 | return { 433 | route: state.routing 434 | } 435 | } 436 | 437 | // route setter handles the route changing. If the page has 438 | // changed (not just a parameter) then we need to clear any 439 | // previous view and render the new one. In this case we're 440 | // assuming that a page called 'view-home' would correspond 441 | // to a web component with the same name 442 | set route(val: RoutingState) { 443 | if (val.page !== this.page) { 444 | const el = document.createElement(val.page) 445 | this.textContent = '' 446 | this.appendChild(el) 447 | this.page = val.page 448 | } 449 | } 450 | } 451 | 452 | customElements.define('app-router', AppRouterElement) 453 | ``` 454 | 455 | You can simply include this `` element in your `index.html` page or the main app-shell component of your application and your app will then render the appropriate view as you navigate. If you include it in another connected component, you could remove the need for it to be connected to the store and do the `mapState` of the routing in the parent, passing it as a property to the router outlet. 456 | 457 | The router outlet can also be an ideal place to code-split your UI, so code for views is only loaded when (and if) someone navigates to them. 458 | 459 | ### Models Integration 460 | 461 | But changing the UI is just one part of what routing can be used for. One thing people often do is use UI components to trigger data fetching, but in my opinion this is an anti-pattern. Not only does it couple the UI components to non-UI concerns, making them more complex to develop and test, but it means that the data fetch doesn't happen _until_ the UI component has been rendered. 462 | 463 | This initial render often means a relatively large UI framework has been loaded plus additional UI components and widgets. Waiting for all this to happen simply delays the fetch and adds latency to the time when your users see data on the screen. 464 | 465 | Utilizing Rdx can make your code easier and faster. Because the state store part of your app can be so small and everything relies on it, it makes sense to load it sooner. When it loads, the models in your store can make use of the routing information to fetch data even though the views to render that data are themselves still being loaded. This ensures that rendering information is not the cumulative time for UI and data to load, but the time of whichever is the slowest. 466 | 467 | If there's a single fetch based on the route (without parameters) then an effect can be setup to check the route and initiate the data fetching (if it hasn't already been done). This would load the todos only when someone navigated to the `todos-view` which would render the list when loaded. 468 | 469 | ```ts 470 | import { createModel, RoutingState } from '@captaincodeman/rdx' 471 | 472 | export default createModel({ 473 | // state and reducers not shown 474 | 475 | effects(store: Store) { 476 | const dispatch = store.getDispatch() 477 | 478 | return { 479 | async 'routing/change'(payload: RoutingState) { 480 | // check the page from routing 481 | switch (payload.page) { 482 | // if we're on the todos-view 483 | case 'todos-view': 484 | // check if we've already loaded them 485 | const state = store.getState() 486 | if (state.todos.ids.length === 0) { 487 | // if not, show the loading indicator 488 | dispatch.todos.request() 489 | // request them 490 | const resp = await fetch(`${endpoint}todos`) 491 | const todos: Todo[] = await resp.json() 492 | // and update the store 493 | dispatch.todos.receivedList(todos) 494 | } 495 | break 496 | } 497 | } 498 | } 499 | } 500 | }) 501 | ``` 502 | 503 | If the data for a route depends on parameter values from the route, it's usually best to set any `selected` state property in a reducer and then use the effect to load the data. As you saw in the [Inter-Model Communication Using Effects](advanced?id=using-effects) section, setting the selected item in the effect _can_ lead to inconsistent state as at that point, the UI could be attempting to render something based on the reducer state, with the model state that relies on the reducer state not yet reflecting it and needing another action dispatch to synchronize it. 504 | 505 | Here's how it might look: 506 | 507 | ```ts 508 | import { createModel, RoutingState } from '@captaincodeman/rdx' 509 | 510 | export default createModel({ 511 | // state and other reducers not shown 512 | 513 | reducers: { 514 | 'routing/change'(state, payload: RoutingState) { 515 | return { 516 | ...state, 517 | selected: payload.params.id 518 | } 519 | } 520 | }, 521 | 522 | effects(store: Store) { 523 | const dispatch = store.getDispatch() 524 | 525 | return { 526 | async 'routing/change'(payload: RoutingState) { 527 | // check the page from routing 528 | switch (payload.page) { 529 | // if we're on the todos-detail-view 530 | case 'todos-detail-view': 531 | // check if we've already loaded it 532 | const state = store.getState() 533 | const id = payload.params.id 534 | if (state.todos.items[id] === undefined) { 535 | // if not, show the loading indicator 536 | dispatch.todos.request() 537 | // request it 538 | const resp = await fetch(`${endpoint}todos/${id}`) 539 | const todo: Todo = await resp.json() 540 | // and update the store 541 | dispatch.todos.received(todo) 542 | } 543 | break 544 | } 545 | } 546 | } 547 | } 548 | }) 549 | ``` 550 | 551 | Note that we still do the fetching in an effect (and we're checking if we really need to fetch it first), but the actual state update of which item is currently selected is done in the reducer. 552 | 553 | Although re-dispatching in an effect in response to route changes does, in some ways, seem to provide more "informative" explicit actions in the store (i.e. you might see `todo/selected` instead of `routing/change`) the drawbacks and extra UI complications it can create far outweigh this slight benefit. 554 | 555 | ## Polyfills 556 | 557 | We've built Rdx to use modern web standards and if you're using a modern browser such as Chrome then Rdx will work directly. Some platform features that it relies on have been added at later times in different browsers, so sometimes a polyfill may be required if you want to support those browsers. Best practice is to leave the loading of polyfills as an application concern, rather than force them on users that don't need them, have them duplicated by different packages, and to provide flexibility for [fast and efficient polyfill loading](https://www.captaincodeman.com/2020/03/10/eternal-polyfilling-of-the-legacy-browser). 558 | 559 | Here are the features that may require polyfills: 560 | 561 | ### queueMicrotask 562 | 563 | The [`queueMicrotask()`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/queueMicrotask) method of the `window` object queues a microtask to be executed at the end of the current event loop. 564 | 565 | A polyfill for this is very small and should be inlined if your target browsers don't support it. 566 | 567 | ```ts 568 | if (typeof window.queueMicrotask !== 'function') { 569 | window.queueMicrotask = callback => Promise.resolve().then(callback) 570 | } 571 | ``` 572 | 573 | ### EventTarget 574 | 575 | The underlying `Store` implementation acts as an [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget), using the browsers inbuilt `addEventListener` for subscriber functionality used internally and available if you want to subscribe to store state changes or implement your own middleware. 576 | 577 | Unfortunately, the [`EventTarget` constructor()](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/EventTarget) is not currently supported in WebKit, so if you want to support Safari users, an additional small (589 byte) polyfill is needed, which can be loaded only when required: 578 | 579 | ```html 580 | 581 | ``` 582 | 583 | Once support for `EventTarget` is added, this polyfill will stop loading automatically, at which point it can be removed. 584 | 585 | Because it's so small you might chose to include it in your bundle to avoid the additional request and possible effect that having a `document.write` statement has on Chrome optimizations. The [demo project](https://github.com/CaptainCodeman/rdx-demo/) demonstrates how to do this. 586 | -------------------------------------------------------------------------------- /docs/api-combineReducers.md: -------------------------------------------------------------------------------- 1 | # combineReducers 2 | 3 | ```ts 4 | function combineReducers(reducers: Reducers): Reducer 5 | ``` 6 | 7 | The `combineReducers` function is a helper that produces a single root-reducer function from multiple reducer functions. It's typically used to combine the reducers for multiple state branches into the single reducer that is needed for a store. Any action dispatched to the root reducer is automatically dispatched to all of the child reducers, with the results combined back into the root state. This happens in a single, synchronous call. 8 | 9 | If you are using the [createModel](api-createModel) and [createStore](api-createStore) functions to setup your models and store, you won't need to use this directly. -------------------------------------------------------------------------------- /docs/api-connect.md: -------------------------------------------------------------------------------- 1 | # connect 2 | 3 | `connect` is a mixin that "connects" a web component to the store. It provides two methods: 4 | 5 | ## mapState 6 | 7 | `mapState(state: State)` passes the current store state to the component to allow it to extract the state it needs. It should return an object who's properties will be applied to the instance. It's good practice to use a package such as [reselect](https://github.com/reduxjs/reselect) to avoid unnecessary updates and also act as a buffer to insulate components from changes to the store state. 8 | 9 | ## mapEvents 10 | 11 | `mapEvents()` defines a mapping from events that the component will listen to, and actions that it can dispatch to the store. The method should return an object that maps the event names to listen to, to the functions that will be called (usually to dispatch an action to the store using details from the event). 12 | 13 | ## Example 14 | 15 | This example shows a simple counter component using [lit-element](https://lit-element.polymer-project.org/) connected to the store to display the current counter state and automatically dispatch actions to increment or decrement it. Not shown: the custom buttons would raise the `increment-counter` and `decrement-counter` custom events, or regular `@click` [lit-html event handlers](https://lit-html.polymer-project.org/) could be used to dispatch actions directly. 16 | 17 | ```ts 18 | import { LitElement } from 'lit-element' 19 | import { connect } from '@captaincodeman/rdx' 20 | import { store, State } from './store' 21 | 22 | export class CounterElement extends connect(store, LitElement) { 23 | @property({ type: Number }) 24 | count 25 | 26 | mapState(state: State) { 27 | return { 28 | count: state.counter 29 | } 30 | } 31 | 32 | mapEvents() { 33 | return { 34 | 'increment-counter': (e: CustomEvent) => store.dispatch.counter.increment(), 35 | 'decrement-counter': (e: CustomEvent) => store.dispatch.counter.decrement(), 36 | } 37 | } 38 | 39 | render() { 40 | return html` 41 | 42 | ${this.count} 43 | 44 | ` 45 | } 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/api-createModel.md: -------------------------------------------------------------------------------- 1 | # createModel 2 | 3 | ```ts 4 | function createModel(model: Model): Model 5 | ``` 6 | 7 | The `createModel` function asserts that the model has the correct properties and consistent types (e.g. the state parameter of each reducer function matches the model state). 8 | 9 | The properties defined on the model includes: 10 | 11 | ## state 12 | 13 | The `state` property defines both the shape (TypeScript type) of the state and the initial state value to use if no predefined state is passed to the `createStore` function. So, the first time a store is initialized the value here will be used. 14 | 15 | *NOTE:* If the store state is configured for [persistence and re-hydration](api-persist) (e.g. persisted to `localStorage`), then on the next app startup the state will be re-hydrated from there and will be used instead of the initial state. 16 | 17 | The type of the model `state` can be a simple value, an array, or an object containing other properties that are values, arrays and objects. It is good practice to only include serializable values in the state. 18 | 19 | Here's an example of a model with a rich state type: 20 | 21 | ```ts 22 | import { createModel } from '@captaincodeman/rdx' 23 | import { Todo } from '../schema' 24 | 25 | interface State { 26 | entities: { [key: number]: Todo } 27 | selected: number 28 | loading: boolean 29 | } 30 | 31 | export default createModel({ 32 | state: { 33 | entities: {}, 34 | selected: 0, 35 | fetching: false, 36 | } 37 | }) 38 | ``` 39 | 40 | Here's a much simpler model that only stores a single value, in this case a `number`: 41 | 42 | ```ts 43 | import { createModel } from '@captaincodeman/rdx' 44 | 45 | export default createModel({ 46 | state: 0 47 | }) 48 | ``` 49 | 50 | ## reducers 51 | 52 | The `reducers` property returns a map of the reducer functions. Each function accepts a `state` property as it's first parameter, which must match the state type defined for the model, and then an optional payload (of any type). The state's (TypeScript) type can be inferred, so doesn't need to be defined in each reducer even if using strict mode in TypeScript. 53 | 54 | Reducers must be pure functions. That means, they only use the parameters passed in to them. They must also return a new (mutated) state or can return the original state passed in if no change is to be applied. 55 | 56 | The same familiar [immutable update patterns as used in Redux](https://redux.js.org/recipes/structuring-reducers/immutable-update-patterns) should be applied. 57 | 58 | Here's a simple example: 59 | 60 | ```ts 61 | import { createModel } from '@captaincodeman/rdx' 62 | 63 | export default createModel({ 64 | state: 0, 65 | 66 | reducers: { 67 | inc(state) { 68 | return state + 1 69 | }, 70 | 71 | add(state, value: number) { 72 | return state + value 73 | }, 74 | 75 | // reset the count when the auth/signOut action is dispatched 76 | 'auth/signOut'(state) { 77 | return 0 78 | } 79 | } 80 | }) 81 | ``` 82 | 83 | This will produce a dispatch type (which will be added to the store dispatch method) of: 84 | 85 | ```ts 86 | interface counter { 87 | inc(): State 88 | add(value: number): State 89 | } 90 | ``` 91 | 92 | Note the return values always match the type of the model `state`. 93 | 94 | These dispatch methods will dispatch action types where the action type name is a combination of the model name and the function name, and the payload is the 2nd parameter (if used). 95 | 96 | So, an auth model with a reducer function: 97 | 98 | ```ts 99 | import { createModel } from '@captaincodeman/rdx' 100 | 101 | interface User { 102 | id: number 103 | name: string 104 | } 105 | 106 | interface AuthState { 107 | user: User | null 108 | statusKnown: boolean 109 | } 110 | 111 | const initial: AuthState = { 112 | user: null, 113 | statusKnown: false, 114 | } 115 | 116 | export default createModel({ 117 | state: initial, 118 | reducers: { 119 | signedIn(state, user: User) { 120 | return { ...state, user, statusKnown: true } 121 | } 122 | } 123 | }) 124 | ``` 125 | 126 | … would provide a strongly-typed dispatch method: 127 | 128 | ```ts 129 | store.dispatch.auth.signedIn({ id: 1, name: 'CaptainCodeman' }) 130 | ``` 131 | 132 | … which would dispatch the action: 133 | 134 | ```json 135 | { 136 | "type": "auth/signedIn", 137 | "payload": { 138 | "id": 1, 139 | "name": "CaptainCodeman" 140 | } 141 | } 142 | ``` 143 | 144 | Reducer functions with a string name containing a `'/'` character allow the model to listen to actions defined and dispatched by other models. 145 | 146 | ## effects 147 | 148 | The `effects` property is a factory function that is passed a `store` parameter which provides access to the store's typed dispatch method and the current state. It should return a map of effect functions, which are similar to the reducers except the functions don't accept the state as the first property and they can be `async`. 149 | 150 | _NOTE:_ It is important that you **always declare `effects`** like this: `effects(store:Store) { /* ... */ }`. I.e. **don't use an arrow function** as this will mess up intellisense (code completion). 151 | 152 | Effect functions can use the same `model/function` string naming to listen to actions defined and dispatched by other models. 153 | 154 | An effect function can match a reducer function of the same name and will be called _after_ the corresponding action has been dispatched to the store reducers and the state mutated. The effect will be passed the same payload originally passed to the reducer. 155 | 156 | Effects that _don't_ have a corresponding reducer function can be dispatched as per the reducers and will create dispatched actions in the same way. It's possible for other models to listen for those actions in their reducers or effects. 157 | 158 | ```ts 159 | import { createModel } from '@captaincodeman/rdx' 160 | import { Store } from '../store' 161 | 162 | interface User { 163 | id: number 164 | name: string 165 | } 166 | 167 | interface AuthState { 168 | user: User | null 169 | statusKnown: boolean 170 | } 171 | 172 | const initial: AuthState = { 173 | user: null, 174 | statusKnown: false, 175 | } 176 | 177 | export default createModel({ 178 | state: initial, 179 | 180 | reducers: { 181 | signedIn(state, user: User) { 182 | return { ...state, user, statusKnown: true } 183 | } 184 | }, 185 | 186 | effects(store: Store) { 187 | // this captures the strongly typed store dispatch 188 | // which effects can use to dispatch other actions 189 | const dispatch = store.getDispatch() 190 | 191 | return { 192 | // this will be called _after_ the signedIn 193 | // reducer has mutated the store state. It 194 | // can call async functions 195 | async signedIn(user: User) { 196 | await logUserSignedIn(user) 197 | }, 198 | 199 | // this has no matching reducer but can still be 200 | // dispatched from the store, call async functions 201 | // and dispatch actions back to the store. It can 202 | // also access the current state of the store (as 203 | // at the time it executes) to make decisions on 204 | // what to dp 205 | async signIn(credentials: { email: string, password: string }) { 206 | const state = store.getState() 207 | if (store.auth.user === null) { 208 | const resp = await fetch('/auth/signin') 209 | const user = await resp.json() 210 | dispatch.auth.signedIn(user) 211 | } else { 212 | // already signed in, ignore 213 | } 214 | }, 215 | } 216 | } 217 | }) 218 | ``` -------------------------------------------------------------------------------- /docs/api-createStore.md: -------------------------------------------------------------------------------- 1 | # createStore 2 | 3 | ```ts 4 | function createStore(config: Config): Store 5 | ``` 6 | 7 | The `createStore` function creates a store based on the config object provided. 8 | 9 | The config object can contain the following properties: 10 | 11 | ## models 12 | 13 | The `models` property should contain a map of all the models to be used by the store. Putting the models in their own modules can help keeping things tidy and all the code for your store state separate from the store setup code. 14 | 15 | ## plugins 16 | 17 | Rdx supports the concept of plugins which can augment the store functionality and add to the state of the models. The `Dispatch` and `Effects` functionality is implemented as plugins that are _always_ loaded. The Routing is a separate, optional plugin, that you can chose to include in your app or not. It should be possible to create additional plugins, such as a simple redux-saga-like middleware if you like the JavaScript Generators approach. 18 | 19 | The `plugins` property is a map of plugins to use. Each plugin can define its own optional `model` (using the [`createModel` function](api-createModel)) and has the following optional lifecycle methods, to allow it to hook into the store: 20 | 21 | ```ts 22 | onModel(store: ModelStore, name: string, model: Model): void 23 | ``` 24 | 25 | Executes as each model for the store is initialized. 26 | 27 | ```ts 28 | onStore(store: ModelStore): void 29 | ``` 30 | 31 | Executes when the store configuration is complete. 32 | 33 | ## state 34 | 35 | The `state` property allows the initial state for the store to be set which is used when re-hydrating saved state or if initiating state from server-rendered JSON embedded in the page. The state can be partial - any state property that is undefined will use the initial state defined for the corresponding model instead. 36 | 37 | ## Example 38 | 39 | Here's a simple but complete example of setting up a store to support DevTools, persistence, routing and async effects with models separated into their own modules. 40 | 41 | ### Store 42 | 43 | The store defines plugins to use, such as routing, and decorators to apply to the store. It does not really know about the models in the store. 44 | 45 | #### state/store.ts 46 | 47 | ```ts 48 | import { createStore, devtools, persist, StoreState, StoreDispatch, ModelStore } from '@captaincodeman/rdx' 49 | import { config } from './config' 50 | 51 | export const store = devtools(persist(createStore(config))) 52 | 53 | export interface State extends StoreState {} 54 | export interface Dispatch extends StoreDispatch {} 55 | export interface Store extends ModelStore {} 56 | ``` 57 | 58 | #### state/config.ts 59 | 60 | ```ts 61 | import { routingPlugin } from '@captaincodeman/rdx' 62 | import createMatcher from '@captaincodeman/router' 63 | import * as models from './models' 64 | 65 | // define application route / view mappings: 66 | const routes = { 67 | '/': 'view-home', 68 | '/*': 'view-404', 69 | } 70 | 71 | const matcher = createMatcher(routes) 72 | const routing = routingPlugin(matcher) 73 | 74 | export const config = { 75 | models, 76 | plugins: { 77 | routing 78 | } 79 | } 80 | ``` 81 | 82 | ### Models 83 | 84 | The models actually define the state and functionality for your store. This is where most of your app state code will be written. 85 | 86 | #### state/models/index.ts 87 | 88 | ```ts 89 | export { auth } from './auth' 90 | export { todos } from './todos' 91 | ``` 92 | 93 | #### state/models/auth.ts 94 | 95 | ```ts 96 | import { createModel } from '@captaincodeman/rdx` 97 | 98 | export const auth = createModel({ 99 | // ... 100 | }) 101 | ``` 102 | 103 | #### state/models/todos.ts 104 | 105 | ```ts 106 | import { createModel } from '@captaincodeman/rdx` 107 | 108 | export const todos = createModel({ 109 | // ... 110 | }) 111 | ``` 112 | -------------------------------------------------------------------------------- /docs/api-devtools.md: -------------------------------------------------------------------------------- 1 | # devtools 2 | 3 | The `devtools` store decorator adds [Redux DevTools](https://github.com/reduxjs/redux-devtools) integration to the store which allows you to inspect and manipulate the store state. 4 | 5 | ```ts 6 | import { createStore, devtools } from '@captaincodeman/rdx' 7 | import { config } from './config' 8 | 9 | export const store = devtools(createStore(config)) 10 | ``` 11 | 12 | ![Redux DevTools Integration](devtools.png) 13 | 14 | Because it's a decorator, it can be combined with other decorators. Each decorator wraps the previous store, down to the original created using the `createStore` function: 15 | 16 | ```ts 17 | import { createStore, devtools, persist } from '@captaincodeman/rdx' 18 | import { config } from './config' 19 | 20 | export const store = devtools(persist(createStore(config))) 21 | ``` -------------------------------------------------------------------------------- /docs/api-persist.md: -------------------------------------------------------------------------------- 1 | # persist 2 | 3 | The `persist` store decorator adds state persistence and hydration to the store. 4 | 5 | ```ts 6 | import { createStore, persist } from '@captaincodeman/rdx' 7 | import { config } from './config' 8 | 9 | export const store = persist(createStore(config)) 10 | ``` 11 | 12 | The default persistence saves the entire state to `localStorage` using `JSON` serialization after each action dispatch and checks for its existence at startup, re-hydrating the state to restore it if found. 13 | 14 | ## Options 15 | 16 | An optional parameters object allows control over when and how to persist state: 17 | 18 | ```ts 19 | import { createStore, persist } from '@captaincodeman/rdx' 20 | import { config } from './config' 21 | 22 | const options = { 23 | // only perist when counter changes 24 | filter: (action) => action.type.startsWith('counter'), 25 | // only save the counter state 26 | persist: (state) => { 27 | const { counter, ...other } = state 28 | return { counter } 29 | }, 30 | } 31 | 32 | export const store = persist(createStore(config), options) 33 | ``` 34 | 35 | ```ts 36 | export interface PersistOptions { 37 | // name sets the state key to use, useful in development to avoid conflict 38 | // with other apps. Default is to use the app location hostname 39 | name: string 40 | 41 | // provide a hook where the serialization could be provided, e.g. by using 42 | // something like https://github.com/KilledByAPixel/JSONCrush. Default is 43 | // the JSON serializer 44 | serializer: { 45 | parse(text: string): any 46 | stringify(value: any): string 47 | } 48 | 49 | // provide a hook where the storage can be replaced. Default is localStorage 50 | storage: { 51 | getItem(name: string): string | null 52 | setItem(name: string, value: string): void 53 | } 54 | 55 | // filter predicate allows control over whether to persist state based on 56 | // the action. Default is to trigger persistence after all actions 57 | filter: (action: Action) => boolean 58 | 59 | // persist allows transforming the state to only persist part of it. 60 | // Default is to persist complete state 61 | persist: (state: S) => Partial 62 | 63 | // delay introduces a delay before the save is performed. If another persist 64 | // is triggered before it expires, the previous persist is cancelled and a 65 | // new one scheduled. This can save doing too many persist operations by 66 | // debouncing the triggering. Default is 0ms 67 | delay: number 68 | } 69 | ``` -------------------------------------------------------------------------------- /docs/api-routing.md: -------------------------------------------------------------------------------- 1 | # routingPlugin 2 | 3 | The `routingPlugin` factory function creates a plugin for integrating [@captaincodeman/router](https://github.com/CaptainCodeman/js-router). 4 | 5 | ```ts 6 | import { routingPlugin } from '@captaincodeman/rdx' 7 | // ... 8 | export const routing = routingPlugin(matcher, options) 9 | ``` 10 | 11 | - `matcher` is a matcher object from the `@captaincodeman/router` package. 12 | - `options` is optional and provides additional configuration for the plugin. See [Routing Options](api-routing?id=routing-options). 13 | 14 | You need to register the plugin when [creating the store](api-createStore?id=plugins): 15 | 16 | ```ts 17 | import { createStore } from '@captaincodeman/rdx' 18 | import * as models from './models' 19 | import { routing } from './routing' 20 | 21 | const config = { models, plugins: { routing }} 22 | export const store = createStore(config) 23 | ``` 24 | 25 | ## Routing Model 26 | 27 | The routing plugin will integrate itself with Rdx by providing its own model. The model will be registered under the name that was provided as the key to the `plugins` section of the `config` option. (In the previous example this name is `routing`.) 28 | 29 | ### Routing State Branch 30 | 31 | The routing model provides state in the following shape: 32 | 33 | ```ts 34 | interface RoutingState { 35 | page: any 36 | params: { [key: string]: any} 37 | queries?: { [key: string]: string | string[] } 38 | } 39 | ``` 40 | 41 | - `page` will hold what ever value the router's matcher resolved. 42 | - `params` will hold any named parameters, that were taken from the URL. 43 | - `queries` is optional and may hold additional parameters, that were taken from the query string part of the URL. See [withQuerystring](api-routing?id=withquerystring). 44 | 45 | ### Routing Reducers 46 | 47 | The routing model provides the following reducer: 48 | 49 | ```ts 50 | type RoutingReducers = { 51 | change: (state: any, payload: RoutingState) => RoutingState 52 | } 53 | ``` 54 | 55 | You can use this to [integrate routing into your own models](advanced?id=models-integration). 56 | 57 | ### Routing Effects 58 | 59 | The routing model provides effects for triggering navigation through the browser's [`History` interface](https://developer.mozilla.org/en-US/docs/Web/API/History): 60 | 61 | ```ts 62 | type RoutingEffects = { 63 | back: () => void 64 | forward: () => void 65 | go: (payload: number) => void 66 | push: (href: string) => void 67 | replace: (href: string) => void 68 | } 69 | ``` 70 | 71 | ## Routing Options 72 | 73 | ```ts 74 | interface RoutingOptions { 75 | transform: (result: Result) => RoutingState 76 | } 77 | ``` 78 | 79 | - `transform` is a callback that is used to transform the "raw" `result` from the router into the full `RoutingState` for the model. 80 | 81 | ### withQuerystring 82 | 83 | If your application needs to access parameters provided through query strings, you can register the `withQuerystring` function as a `transform` on the `RoutingOptions`: 84 | 85 | ```ts 86 | // other imports and code omitted 87 | import { routingPlugin, withQuerystring } from '@captaincodeman/rdx' 88 | 89 | const options = { transform: withQuerystring } 90 | const routing = routingPlugin(matcher, options) 91 | ``` 92 | 93 | This is provided as _opt-in_, as the code for parsing the query string increases the bundle size of your application and not every application actually needs it. 94 | -------------------------------------------------------------------------------- /docs/api-store.md: -------------------------------------------------------------------------------- 1 | # Store 2 | 3 | The `Store` class is the low-level state store in Rdx. You won't normally use it directly but it is available if you just require a simple Redux-like state store in far fewer bytes. 4 | 5 | TODO: handles dispatch, events, inherits from `EventTarget` (may require polyfill in older Safari) 6 | 7 | ## constructor 8 | 9 | ```ts 10 | new Store(state: State | undefined, reducer: (state: State, action: Action) => State) 11 | ``` 12 | 13 | Create an instance, passing in the existing state to start with (if re-hydrating or including as server-rendered JSON) together with the root reducer functions to use (reducers can be combined with the `combineReducers` function) 14 | 15 | ```ts 16 | const reducer = (state = 0, action) => { 17 | switch (action.type) { 18 | case 'add': 19 | return state + action.payload 20 | default: 21 | return state 22 | } 23 | } 24 | 25 | const store = new Store(undefined, reducer) 26 | ``` 27 | 28 | ## dispatch Method 29 | 30 | ```ts 31 | store.dispatch(action: Action) 32 | ``` 33 | 34 | The `dispatch` method is used to dispatch an action to the store. The store will raise an `action` event, that middleware subscribers can handle if required (either cancelling the action, or transforming it). If not cancelled, the store reducer is called to mutate the state and a `state` event is then raised. Any subscriber interested in state changes (e.g. UI components) can subscribe to this event to be notified that they should render updates. 35 | 36 | ## reducer Property 37 | 38 | ```ts 39 | store.reducer: Reducer 40 | ``` 41 | 42 | The `reducer` function is available as a property of the store. It is only used to allow the reducer function to be replaced / augmented, such as when lazy-loading parts of the store. Rdx is so small, that this is rarely necessary. 43 | 44 | ## state Property 45 | 46 | ```ts 47 | store.state: State 48 | ``` 49 | 50 | The `state` property returns the current state of the store. 51 | -------------------------------------------------------------------------------- /docs/api-thunk.md: -------------------------------------------------------------------------------- 1 | # thunk 2 | 3 | _NOTE:_ Support for _thunks_ is provided as a compatibility feature for learning or migration purposes. We strongly suggest, you use the [async effects plugin](advanced?id=effects) built-in with Rdx instead. 4 | 5 | The `thunk` store decorator adds support for 'thunks'. A thunk allows a function to be dispatched to the store instead of a regular action. This decorator acts as middleware, intercepting `thunk` actions and executing them. 6 | 7 | When a thunk is executed, it is passed the store `dispatch` function and a `getState` function so it can make decisions about the current state of the store and dispatch other actions if required. 8 | 9 | It's typically used as a lightweight, cheap-as-chips, asynchronous middleware, which can be useful for learning the basics of state containers, simple use-cases and early prototyping. It's easy to quickly outgrow what it provides and Rdx contains a more powerful [async effect middleware](advanced?id=effects) built-in, so it's not recommended, other than for learning or code migration purposes. 10 | 11 | ```ts 12 | import { createStore, thunk } from '@captaincodeman/rdx' 13 | import { config } from './config' 14 | 15 | export const store = thunk(createStore(config)) 16 | ``` -------------------------------------------------------------------------------- /docs/auth-anonymous.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/auth-anonymous.png -------------------------------------------------------------------------------- /docs/auth-authenticated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/auth-authenticated.png -------------------------------------------------------------------------------- /docs/background.md: -------------------------------------------------------------------------------- 1 | # Introducing Rdx, a Tiny State Store 2 | 3 | ## Like Redux, but Smaller (and Easier) 4 | 5 | If you've worked on client-side web apps for any length of time then it's almost guaranteed that you are familiar with, or at least aware of, [Redux](https://redux.js.org/). Redux advertizes itself as "A Predictable State Container for JS Apps" and is of course wildly successful as its 5m weekly npm downloads show. 6 | 7 | But while it's easy to love what Redux _does_ it's not uncommon to find it a little annoying to work with. One frequent complaint is that it requires a _lot_ of boilerplate code and the configuration and setup is complex and confusing. The code for a Redux state store can end up fragmented and spread over multiple files but have to be in sync to work together and this can make it a challenge to use and learn, especially for beginners. 8 | 9 | In fact, this is admitted to some degree in rather round-about fashion by its author Dan Abramov. In 2016 he wrote an article ["You Might Not Need Redux"](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367) where he argues that yes, it _is_ fragmented and difficult, but that is "by design" and effectively the price you pay for what it does. If you don't like it, you're probably using it when you don't need to and you should stick to React. 10 | 11 | Is that actually true though? Are we stuck with Redux as it is? Can we not have the benefits without the cost? It it too much to ask for a state store to be simple and easy to use and appropriate for smaller projects while also providing the type-safety that we would like for larger ones? 12 | 13 | I think you _can_ have the benefits that Redux offers, with an easier to use API, and in far fewer bytes. Introducing Rdx - like Redux, but smaller. 14 | 15 | 16 | 17 | ## Redux History 18 | 19 | In many ways Redux is a product of time and circumstance. It was inspired by [Facebook's Flux Architecture](https://facebook.github.io/flux/) and applied functional programming techniques which were the cool, in-vogue thing to be used at the time. 20 | 21 | One of the issues with programming trends is that they sometimes have a habit of being over applied. I think in hindsight, while the "reducer" concepts of Redux are fantastic, the use of [function currying](https://medium.com/javascript-scene/curry-and-function-composition-2c208d774983) for configuration just makes it way more complex to use than is really justified and leads to problems learning and understanding it. I've used Redux for many years and honestly I have to re-check the docs everytime I start a new project and need to setup the store configuration (or else copy it from the last project). 22 | 23 | The complexity introduced by these design decisions also contributes to code size. While the core Redux package itself is only 7.3Kb minified, a significant amount of that code is only there because it allows configuration functions with parameters in different places which then have to be checked and swapped round if necessary (why?) or to output error messages (including developer-level documentation) to tell you if (when) you got things wrong. 24 | 25 | But should 7.3Kb really be considered "small"? Along with React, it benefitted by comparisons with other frameworks of the day, such as Angular.JS weighing in at ~150Kb but we should always judge a JS package size based on whether it's _really_ necessary to do its task. It's possible to write what Redux does in less than 1Kb and there are many Redux-like packages such as [unistore](https://www.npmjs.com/package/unistore) that do just that. Perhaps it could have been fixed, but because Redux's popularity exploded quickly, it looks like the time for refining the API had been missed. 26 | 27 | But it's not just the configuration that is difficult. The code that you write for your application state also ends up being overly complex and confusing, because of how Redux is designed and implemented. 28 | 29 | ## Boilerplate 30 | 31 | Let's look at what the issue is with Redux and why beginners find it confusing. It starts off simple, there are just three main pieces to a Redux state container: 32 | 33 | - **Store**: The central store to hold the state of the application, the "single source of truth" 34 | - **Reducer**: The reducer function that the store uses to mutate the state in a predictable manner 35 | - **Actions**: The actions, that you dispatch to the store, which the reducer function handles 36 | 37 | It does sound very simple, but in reality there are usually lots of other ancilliary pieces required to combine separate reducers and to create actions consistently. Yes, you can use Redux without these, but only for trivial demos and tutorials to give a rather misleading portrayal of how simple it can all be - if you're using it for any real app of substance, you'll almost certainly be using these extra pieces. 38 | 39 | Let's take this [TODO Example](https://redux.js.org/basics/example) from the Redux docs but instead of just copying the code as-is, I'm going to apply the best-practices that are also in the Redux docs, but unfortunately not applied in the examples. I'm also going to add TypeScript, because it's increasingly common to use and useful to have strongly-typed code in a larger app: 40 | 41 | ### Todo State Branch 42 | 43 | Stored in a separate folder as per the ["ducks"](https://github.com/erikras/ducks-modular-redux) approach: 44 | 45 | #### todos/models.ts 46 | 47 | Defines the shape of our entities inside our store and when used as properties in UI components or requests for remote data (e.g. REST API) 48 | 49 | ```ts 50 | export interface Todo { 51 | id: number 52 | name: string 53 | text: string 54 | } 55 | ``` 56 | 57 | #### todos/actions.ts 58 | 59 | The action "types" need to be defined, i.e. the string constants used in the `type` property of the action objects. These constants are used in the definition of the TypeScript types of the actions (not to be confused with the `type` property), the action creators, and the reducer(s). Using the constants helps to avoid subtle errors that typos would introduce (e.g. `'ADD_TODO' != 'ADD-TODO'`). The actions are strongly typed with the `type` property acting as a discriminator, which allows us to refer to the strongly typed payload inside the reducer(s). 60 | 61 | ```ts 62 | export const ADD_TODO = 'ADD_TODO' 63 | 64 | export interface AddTodoAction { 65 | type: typeof ADD_TODO 66 | payload: { 67 | name: string 68 | text: string 69 | } 70 | } 71 | 72 | export type TodoActions = AddTodoAction // | OtherActions ... 73 | ``` 74 | 75 | #### todos/actionCreators.ts 76 | 77 | We want to create actions consistently and ensure they are the correct type, so anytime we want to create one, we use a factory function. 78 | 79 | ```ts 80 | import { ADD_TODO, AddTodoAction } from './actions' 81 | 82 | export const addTodo = (name: string, text: string): AddTodoAction => { 83 | return { 84 | type: ADD_TODO, 85 | payload: { 86 | name, 87 | text, 88 | } 89 | } 90 | } 91 | ``` 92 | 93 | #### todos/reducer.ts 94 | 95 | The reducer module defines the shape of the `todos` branch of the state store, and the reducer function to handle any dispatched actions that it needs to respond to. Note the catch-all `default` handler which _must_ return the unchanged state if it isn't being mutated, otherwise Redux will not function correctly. Some like to use helper libs such as [immer](https://immerjs.github.io/immer/) to make the immutable updates easier, but the spread operators available in modern JS make them quite simple to implement natively. 96 | 97 | ```ts 98 | import { TodoActions, ADD_TODO } from './actions' 99 | import { Todo } from './models' 100 | 101 | export interface TodosState { 102 | items: Todo[] 103 | last_id: number 104 | } 105 | 106 | const initialState: TodosState = { 107 | items: [], 108 | last_id: 0, 109 | } 110 | 111 | export function todos(state: TodoState = initialState, action: TodoActions) { 112 | switch (action.type) { 113 | case ADD_TODO: 114 | const last_id = state.last_id + 1 115 | const todo = { 116 | id: last_id, 117 | name: action.payload.name, 118 | text: action.payload.text, 119 | } 120 | 121 | return { 122 | ...state, 123 | last_id, 124 | items: [...state.items, todo], 125 | } 126 | 127 | default: 128 | return state 129 | } 130 | } 131 | 132 | export default todos 133 | ``` 134 | 135 | ### Root Reducer and Store 136 | 137 | #### reducer.ts 138 | 139 | The root reducer combines the separate reducers for each state branch into a single reducer function. 140 | 141 | ```ts 142 | import { combineReducers } from 'redux' 143 | import todos, { TodosState } from './todos/reducer' 144 | import filter, { FilterState } from './filter/reducer' // other state branch 145 | 146 | export interface RootState { 147 | todos: TodosState 148 | filter: FilterState 149 | } 150 | 151 | export default combineReducers({ 152 | todos, 153 | filter, 154 | }) 155 | ``` 156 | 157 | #### store.ts 158 | 159 | The state store uses the combined root reducer to create the store. It's typical to add extra middleware to handle async actions ("thunks"), persisting state for fast re-start (e.g. to `localStorage`) or to wire-up the Redux DevTools for inspection and debugging purposes. 160 | 161 | ```ts 162 | import { compose, createStore, applyMiddleware, Store } from 'redux' 163 | import thunkMiddleware from 'redux-thunk' 164 | import reducer, { RootState } from './reducer' 165 | 166 | declare global { 167 | interface Window { 168 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any 169 | } 170 | } 171 | 172 | const composeEnhancers = (typeof window !== 'undefined' && 173 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose 174 | 175 | function configureStore(preloadedState: RootState): Store { 176 | return createStore( 177 | reducer, 178 | preloadedState, 179 | composeEnhancers(applyMiddleware(thunkMiddleware)) 180 | ) 181 | } 182 | 183 | export const store = configureStore(undefined) 184 | ``` 185 | 186 | ## The Problem 187 | 188 | This. Is. Too. Verbose. 189 | 190 | The same names are repeated over and over and over, and this is the code to handle _one_ single action in _one_ state branch. Not only that, but the code is often spread out over several files. Even with nice IDEs and refactoring tools, it's complex to work with. If you want to add another action, you are likely going to be touching multiple files. 191 | 192 | I believe this is why beginners struggle with Redux. It creates too many moving parts that are in too many different places. It's not like normal coding where you're maybe working on a "class" that is self-contained and you just need to keep in your head what it is doing. In the article linked above, Dan Abramov claims these are needed: 193 | 194 | > Redux offers a tradeoff. It asks you to: 195 | > * Describe application state as plain objects and arrays. 196 | > * Describe changes in the system as plain objects. 197 | > * Describe the logic for handling changes as pure functions. 198 | 199 | But it doesn't mean that we _need_ to use actions, action types, action creators etc... as prescribed by Redux. What if those could all be created for us? 200 | 201 | ## Reversing the Definition 202 | 203 | Why should we need to define action names and action types and action creators and remember to return the default state if the action isn't handled? Why do I even care about actions, why can't I just write reducer functions? 204 | 205 | We _know_ we need the reducer function, that is the core part of the _concept_ of a predictable state container, that starting with a given state and applying a pure function to it will produce a predicable (and testable) mutation of the state. 206 | 207 | An action is effectively a serializable function call. It specifies the function to call (from the action `type`) and the parameters it needs (the action `payload`). If we have the reducer function, we shouldn't need to define action types, action interfaces or action creator functions. 208 | 209 | So how about instead of the reducer above, we simply define the separate reducer function(s) we want, and each function can define the payload it requires as it's parameter (in addition to the state)? We might end up with something like this: 210 | 211 | ```ts 212 | const reducers = { 213 | add(state: TodoState, payload: { name: string, text: string }) { 214 | const last_id = state.last_id + 1 215 | const todo = { 216 | id: last_id, 217 | name: action.payload.name, 218 | text: action.payload.text, 219 | } 220 | 221 | return { 222 | ...state, 223 | last_id, 224 | items: [...state.items, todo], 225 | } 226 | } 227 | } 228 | ``` 229 | 230 | How could it work and what would we gain? 231 | 232 | ### Simpler Reducers 233 | 234 | Each reducer function is independent. We don't need to remember to add a `default` fall-through branch to return the state unaltered, and we don't need any discriminators inside a `switch` to get the strong typing - each one is inherently a strongly typed function that is also easy to unit test. 235 | 236 | ### Action Names 237 | 238 | We could use the state branch and function name to generate the unique action name automatically and consistently. So the `add` reducer function for the `todos` state would have the type `todos/add` (which incidentally, is now the recommended best-practice for naming things in the Redux docs). 239 | 240 | ### Action Types 241 | 242 | The only other thing we require in order to define an action, besides the action name, is the payload parameter of the reducer function. We don't need to define that again. 243 | 244 | ### Action Creators 245 | 246 | If we know the action type and the payload type, we can automatically generate an action creator for it. It's effectively the same function signature as the reducer, but without the `state` passed in. We take the `payload` parameter, combine it with the generated type string, and have our creator, as though we had ourselves written: 247 | 248 | ```ts 249 | export const TODOS_ADD = 'todos/add' 250 | 251 | export const add = (payload: { name: string, text: string }) => { 252 | return { 253 | type: TODOS_ADD, 254 | payload, 255 | } 256 | } 257 | ``` 258 | 259 | After all, what is an action _really_ besides a form of serialized function call? Start with the function call, the reducer, which is the most important part, and work backwards from that. 260 | 261 | ## The Solution 262 | 263 | This is what Rdx does. It handles all the wiring for you. You only need to define the state and the reducer functions and _everything else is generated for you from those_. You get a fully type-safe store dispatch method that acts as the action creator, so you can, for instance, just call: 264 | 265 | ```ts 266 | dispatch.todos.add({ name: 'Buy Milk', text: '1 litre of semi-skimmed / 2%' }) 267 | ``` 268 | 269 | This will still dispatch a familiar looking action to the store: 270 | 271 | ```json 272 | { 273 | "type": "todos/add", 274 | "payload": { 275 | "name": "Buy Milk", 276 | "text": "1 litre of semi-skimmed / 2%" 277 | } 278 | } 279 | ``` 280 | 281 | In fact, because it is _so_ similar, it can be wired up to use the Redux DevTools, so we keep all the great debugging and inspection tools. 282 | 283 | Creating a store is significantly simpler: 284 | 285 | ```ts 286 | import { createStore, devtools } from '@captaincodeman/rdx' 287 | import todos from './todos' 288 | 289 | export const store = devtools(createStore({ models: { todos } })) 290 | ``` 291 | 292 | ## Prior Art 293 | 294 | At this point, you may be thinking "I've seen things like this already" and if, like me, you've searched for solutions to the pain of Redux boilerplate you'll find pre-existing solutions such as: 295 | 296 | * https://github.com/rematch/rematch/ 297 | * https://github.com/dvajs/dva 298 | * https://github.com/mirrorjs/mirror 299 | * https://github.com/HenrikJoreteg/redux-bundler 300 | 301 | `rematch` was really the main inspiration for Rdx and it's worth reading their reasoning behind [Redesigning Redux](https://hackernoon.com/redesigning-redux-b2baee8b8a38) which really resonated with me. 302 | 303 | So there's definitely lots of "prior art", but I see that as validation that this approach is worthwhile, but people rarely took the final logical step of removing Redux itself, which ultimately limits the benefits and leaves the convoluted configuration. 304 | 305 | All the solutions I found suffered from one or more of these issues: 306 | 307 | * They are built on top of Redux, so add more to the bundle size of your app. 308 | * Don't play well with TypeScript to provide fully typed state and dispatch. 309 | * Are deprecated and no longer actively developed / supported. 310 | 311 | The latter isn't always an issue if a library is small in scope and complete / stable. Plus, if you're using open-source libs, the _only_ contract you really have is that you have access to the source code, so ultimately you yourself are responsible for any long-term support you might need. 312 | 313 | But I really _want_ to have full TypeScript support and lately I've become a little obsessed with bundle sizes. 314 | 315 | ## Bundle Size 316 | 317 | There is lots of guidance about the danger of shipping too much JavaScript for your app. 318 | 319 | TODO: Link to articles about bundle size, talks by Alex Russell, Adi Osmani etc... 320 | https://medium.com/@addyosmani/the-cost-of-javascript-in-2018-7d8950fbb5d4 321 | 322 | Remember I said I thought 7.3Kb for Redux might be too much? If it was _just_ that and if it was all absolutely required for it's functionality, it may not be so bad, but we know it isn't all necessary and in a typical app you usually require additional pieces such as async effect handling (middleware) and routing. This is where we get into the sad state of JavaScript frameworks. 323 | 324 | ### Reasons for Bundle Bloat 325 | 326 | What would the typical solution to having "too much" of something be? Have less of it maybe? 327 | 328 | Not in the world of JavaScript! If you actually look at the bundle size of out-the-box apps from the latest frameworks, you find an incredible amount of bloat and a lot of it appears to be there as the solution to there being too much JS in the first place. 329 | 330 | Wha...? Let me explain... 331 | 332 | Someone writes a framework. It contains a lot of JS. Maybe not as much as some of the other frameworks, but it was envisaged before much of what it provided was built into the web platform and is already available on every modern browser. So it's larger than it now needs to be, and not as fast as it could be as a result. More bytes means slow download and parsing + execution time. 333 | 334 | _Question:_ How do you address the performance issues and make it faster? 335 | 336 | _Option 1:_ Accept that it's become like jQuery and is no longer defacto essential for building web-apps. Rejoice that it helped to push the evolution of the web forward and enjoy the web-features it inspired, with smaller bundles and faster apps. 337 | 338 | _Option 2:_ Stick to the belief that it's the "one true way" and add _additional_ code to support it running on the server, so it can generate a static view for fast initial loading and render (*cough* definitely not to cheat benchmarks *cough*) using "Server Side Rendering" (SSR) and then continue to use the same code, now with extra pieces added and therefore slower to load, on the client. 339 | 340 | Of course, the answer was Option 2. If you follow the [Create React App](https://reactjs.org/docs/create-a-new-react-app.html) instructions and then [add a router](https://create-react-app.dev/docs/adding-a-router/) you are [adding nearly 30Kb to your bundle](https://bundlephobia.com/result?p=react-router-dom@5.2.0) and a significant part of that is code that already exists natively on the client, but is required to allow the same code to run on the server. 341 | 342 | Yes, you send _additional_ code to the client which it doesn't need, because what it does is already built in, just so the same code can also run on the server (where a client needs to be 'emulated') to allow the page to be pre-rendered ... because the _previous_ code being sent to the client was already too large and otherwise made it too slow. 343 | 344 | This is _insane_ isn't it?! Thanks, JavaScript world! 345 | 346 | But it gets worse. As you learn to make more use of Redux you discover that there is more value as you make state information available in the store. You can react to actions and state changes which could be fetching data. The trigger for doing this is often routing information - what view is the user looking at and what information needs to be fetched to provide it? Don't worry, the Redux ecosystem has you covered by [adding another 10Kb to your bundle](https://bundlephobia.com/result?p=connected-react-router@6.8.0) to make it possible. 347 | 348 | That's just routing. You also typically need some middleware to handle side-effects in your store, transforming the asynchronous operations of fetching data into synchronous actions dispatched to the store. If you only need something simple, you might start with [redux-thunk](https://github.com/reduxjs/redux-thunk) which is tiny, but its limitations mean that most real-life apps will outgrow it very quickly and you'll typically be using something such as [redux-saga](https://redux-saga.js.org/) or [redux-observable](https://redux-observable.js.org/), both of which have an additional 'learning curve' (where the curve is a mountain). 349 | 350 | These can add another 15Kb to you bundle, not to mention the additional code that you add to your app to use them. Hear that? It's the sound of your [lighthouse](https://developers.google.com/web/tools/lighthouse/) score dropping ... 351 | 352 | It's not to say _what_ this all does, the end result, is bad or wrong. You get a working app and there are reasons for how things have developed to where they are. But every now and then it's worth questioning whether all those reasons are still valid today. I think all this JS is too much and you can get the same results with far less. 353 | 354 | ## Rdx, Tiny but Complete 355 | 356 | One of the things I wanted with Rdx is something that provided all the basic features that you typically need in a state store for an app, in a tiny bundle size, with an API that also reduces the amount of app code you have to write. 357 | 358 | Here's what you get with just 4Kb minified / 1.83Kb gzipped JavaScript (about half the size of Redux alone): 359 | 360 | * Redux-like state container 361 | * Integration with Redux DevTools 362 | * Connect mixin to bind web components to the store 363 | * Reducer definitions to auto-generate action creators and types 364 | * Fully stong-typed TypeScript typing of state and dispatch functions 365 | * Routing middleware to add route state to store, with parameter extraction 366 | * Async effect middleware with easy-to-use semantics 367 | * Persistence middleware to re-hydrate or persist state (e.g. to `localStorage`) 368 | 369 | The best part though is how much easier it makes the development of your application. Your state store no longer needs so much boilerplate and isn't such a chore to work with. 370 | 371 | Let me know what you think! 372 | -------------------------------------------------------------------------------- /docs/devtools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/devtools.png -------------------------------------------------------------------------------- /docs/dispatch-intellisense.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/dispatch-intellisense.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @captaincodeman/rdx - Like Redux, but smaller 6 | 7 | 8 | 9 | 15 | 16 | 17 |
18 | 33 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Rdx is like Redux, but smaller. 4 | 5 | It is a fully featured state container, that provides all the core functionality most apps require without adding a large payload to your bundle or requiring excessive boilerplate code — your app runs faster and you write less code. 6 | 7 | Here's what you get with **_just 1.83Kb_** of JavaScript added to your app: 8 | 9 | * A predictable, Redux-like, state container 10 | * Integration with Redux DevTools for inspection and debugging 11 | * Connect mixin to bind web component properties to the store & dispatch actions from events 12 | * Simpler definition of reducers with auto-generated action creators and action types 13 | * Strongly-typed State and Dispatch functions for use with TypeScript 14 | * Routing middleware to add route data to state store, with parameter extraction 15 | * Effect middleware for asynchronous code (respond to actions, fetch data etc…) 16 | * Persistence middleware to persist and re-hydrate state (e.g. to `localStorage`) 17 | 18 | All that, for less than the size of Redux alone. 19 | 20 | Not all apps need to persist and re-hydrate state and you may want to exclude DevTools integration for production. Without those the size becomes **_just 1.43Kb_**. 21 | 22 | ## Redux Approach 23 | 24 | Rdx does exactly what Redux does, it just reverses how you define the reducers, action types, action interfaces and action creators. All you need to do is define the initial state and reducer functions (in a simpler way) and Rdx does the rest. 25 | 26 | Here's the basic terminology explained based on how Redux works which will show how Rdx makes things significantly simpler. 27 | 28 | An `Action` is a Plain Old JavaScript Object (*POJO*) that is serializable. It consists of a `type` property, which is a string that uniquely identifies that action in the store, and an optional `payload` property, which can contain additional information about the action, that the reducer might need to mutate the state. The payload can be a simple value or a complex object or array. 29 | 30 | Here is an example of a simple `Action`: 31 | 32 | ```json 33 | { 34 | "type": "counter/add", 35 | "payload": 5 36 | } 37 | ``` 38 | 39 | In this case, the Action `type` is the string `counter/add`. 40 | 41 | We could also describe the type and the interface for this action to specifically be: 42 | 43 | ```ts 44 | export const COUNTER_ADD = 'counter/add' 45 | 46 | export interface CounterAddAction { 47 | type: COUNTER_ADD, 48 | payload: number, 49 | } 50 | 51 | export type CounterActions = CounterAddAction 52 | | CounterSubtractAction 53 | | CounterIncrementAction 54 | | CounterDecrementAction // (additional types not shown) 55 | ``` 56 | 57 | NOTE: Technically, the `CounterAddAction` is the TypeScript _type_ of the action (object). But it can be confusing to refer to an action's type when every action also has a `type` property. So we'll try to stick to `ActionInterface` for the TypeScript _shape_ of a particular Action (it's a pity that `type` wasn't called `name`, which would be less confusing). 58 | 59 | When using Redux, it's typical to also define an *action creator*, which is a function to create the action in a consistent way. 60 | 61 | ```ts 62 | import { COUNTER_ADD, CounterAddAction } from './actions' 63 | 64 | export function counterAdd(amount: number): CounterAddAction { 65 | return { 66 | type: COUNTER_ADD, 67 | payload: amount, 68 | } 69 | } 70 | ``` 71 | 72 | Finally, we get to define a *reducer*. In Redux, this would be something like: 73 | 74 | ```ts 75 | import { CounterActions, COUNTER_ADD } from './actions' 76 | 77 | const counter = function(state: number = 0, action: CounterActions) { 78 | switch (action.type) { 79 | case COUNTER_ADD: 80 | // `action.type` acts as a 'type discriminator' in TypeScript. 81 | // So at this point, TypeScript knows, that the payload is a number. 82 | return state + action.payload 83 | 84 | default: 85 | // This default switch handler is _very_ important in Redux: 86 | // If you forget to add it, your store will be broken! 87 | return state 88 | } 89 | } 90 | ``` 91 | 92 | Are you tired of typing variations of "Counter Add" yet? 93 | 94 | At this point, you configure and create a `Store` instance (not shown), which allows you to retrieve the current state of your application and dispatch actions to mutate it in a predictable manner: 95 | 96 | ```ts 97 | import { counterAdd } from './actions' 98 | import { store } from './store' 99 | 100 | let state = store.getState() 101 | // initial state is { counter: 0 } 102 | 103 | store.dispatch(counterAdd(5)) 104 | // dispatches the action { "type": "counter/add", "payload": 5 } 105 | 106 | state = store.getState() 107 | // updated state is now { counter: 5 } 108 | ``` 109 | 110 | Hopefully, that short overview gives you an idea of the important parts of Redux. It's actually very simple, it just makes things _appear_ complex because there are so many fragmented parts that you need to write — the need to define all these pieces is why people complain about the "boilerplate" in Redux. It can make coding with Redux a little laborious. 111 | 112 | ## Rdx Equivalent 113 | 114 | Rdx removes the need for all that code. An action is effectively a serializable function call, with the function name as the `type` and parameters as the `payload`, so if we _have_ to write the reducer function we can infer the action, action type and action creator from it rather than have to define them ourselves. So you effectively only need to write the initial state and reducer function(s): 115 | 116 | ```ts 117 | import { createModel } from '@captaincodeman/rdx' 118 | 119 | export const counter = createModel({ 120 | state: 0, 121 | reducers: { 122 | add(state, value: number) { 123 | return state + number 124 | } 125 | } 126 | }) 127 | ``` 128 | 129 | Through a combination of Rdx and TypeScript, the above will give you a strongly typed state store **and** dispatch methods, just like the action creators you had to write yourself. Using our store becomes simpler and we don't need to export / import as many pieces: 130 | 131 | ```ts 132 | import { store } from './store' 133 | 134 | // initial store.state is { counter: 0 } 135 | 136 | store.dispatch.counter.add(5) 137 | // dispatches the action { "type": "counter/add", "payload": 5 } 138 | 139 | // updated store.state is now { counter: 5 } 140 | ``` 141 | 142 | Because it's all strongly typed we get full intellisense when using the store state or the dispatch. 143 | 144 | ![](strongly-typed-state.png) 145 | ![](strongly-typed-dispatch.png) 146 | ![](dispatch-intellisense.png) 147 | 148 | There are many additional savings when it comes to store configuration and creation, routing and asynchronous effects. Overall the amount of code you have to write for your app is reduced, and the code you write is simpler. You get a smaller bundle size and develop quicker. 149 | -------------------------------------------------------------------------------- /docs/plugin-dispatch.md: -------------------------------------------------------------------------------- 1 | # dispatch Plugin 2 | 3 | TODO -------------------------------------------------------------------------------- /docs/plugin-effects.md: -------------------------------------------------------------------------------- 1 | # effects Plugin 2 | 3 | TODO -------------------------------------------------------------------------------- /docs/plugin-routing.md: -------------------------------------------------------------------------------- 1 | # Routing Plugin 2 | 3 | Rdx provides a plugin for integrating [@captaincodeman/router](https://github.com/CaptainCodeman/js-router), a tiny routing library for use in SPAs / PWAs. The plugin [provides its own model](api-routing?id=routing-model), making it a first-class citizen of Rdx, and automatically hooks into the browser's navigation by [intercepting hyperlinks](plugin-routing?id=intercepting-hyperlinks) and [integrating with the browser history](plugin-routing?id=history-integration). So all your users' interactions that result in navigation, be it clicking on a link or using the back button, happen "in-band" of your app's state and your app can react accordingly. Also, if you programmatically trigger navigation, this integrates nicely with the browser's native navigation (i.e. it doesn't "break the back button"). All of this works towards a positive user experience. 4 | 5 | ## Browser Integration 6 | 7 | Users don't like surprises. They expect the back button to go back to the last thing they were looking at, and they expect clicking on a link to take them somewhere else to look at. It's the fundamental way of how the web works, and users are well trained to expect this behaviour. 8 | 9 | Browsers were originally built for loading and displaying an HTML page, and then possibly navigating to another page, loading and displaying that. But when writing an SPA (single page application), you break with this traditional approach; you want all URLs inside your app ("below your app's starting point") to be handled by your app's (JavaScript) code, instead of triggering network requests for different pages. 10 | 11 | But, for a good user experience, you still want the back button and hyperlinks (i.e. `` elements) to look and work like they always do. The fact, that you chose to implement your app as an SPA and not with multiple pages is an implementation detail the user should not be concerned with. 12 | 13 | The routing plugin integrates with the browser to help you achieve this. 14 | 15 | ### Intercepting Hyperlinks 16 | 17 | To prevent your user from accidentally navigating away from your app, the routing plugin registers a universal event handler for intercepting clicks on `` elements. If the URL of a hyperlink points to a page inside your app, the default behaviour (i.e. sending a network request) is prevented and instead the router will be called. In many other cases though, the browser's native behaviour is preserved in order to not surprise or upset your users (or you). 18 | 19 | Native behaviour means to _follow_ the link, which will occur if ... 20 | 21 | - ...the link destination is not within the app. 22 | - ...the hyperlink anchor has the `rel` attribute set and it contains `external`. 23 | - ...the hyperlink anchor has the `download` attribute set. 24 | - ...it's not just a regular click (i.e. not with the primary button, or with a modifier key held down). 25 | 26 | ### History Integration 27 | 28 | The routing plugin integrates with browser history in both directions: When the user navigates, or when your app triggers navigation. 29 | 30 | The plugin registers for changes to the browser history / navigation [through the popstate event](https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event). So when your user navigates by using the browser history (e.g. the back button), the information about that goes to the router and, if the resulting routing state changed, the change gets recorded in your Rdx state store. 31 | 32 | The plugin further allows you to programmatically navigate by using the [`History` interface](https://developer.mozilla.org/en-US/docs/Web/API/History). This is exposed through [effects on the model of the plugin](api-routing?id=routing-effects). 33 | 34 | ## Example 35 | 36 | This is a short example how you would set-up the routingPlugin. 37 | 38 | ### src/state/config.ts 39 | 40 | ```ts 41 | import createMatcher from '@captaincodeman/router' 42 | import { routingPlugin } from '@captaincodeman/rdx' 43 | import * as models from './models' 44 | 45 | // map the URL pattern (optionally with named parameters) 46 | // to any value. strings will do just fine. 47 | const routes = { 48 | '/': 'home', 49 | '/todos': 'todo-list', 50 | '/todos/:id': 'todo-details', 51 | '/*': 'not-found', 52 | } 53 | 54 | const matcher = createMatcher(routes) 55 | const routing = routingPlugin(matcher) 56 | 57 | export const config = { models, plugins: { routing } } 58 | ``` 59 | 60 | ### src/state/store.ts 61 | 62 | ```ts 63 | import { 64 | createStore, 65 | ModelStore, 66 | StoreState, 67 | StoreDispatch, 68 | } from '@captaincodeman/rdx' 69 | import { config } from './config' 70 | 71 | export const store = createStore(config) 72 | export interface State extends StoreState {} 73 | export interface Dispatch extends StoreDispatch {} 74 | export interface Store extends ModelStore {} 75 | ``` 76 | 77 | ### Afterthoughts 78 | 79 | Now that the routing plugin is set up, check out the advanced usage guide and see you how to [render different views in a router outlet](advanced?id=router-outlet) based on that routing information, and also how to [early start loading data](advanced?id=models-integration) (i.e. don't wait for the view to load, but start fetching data as soon as the route changes). 80 | 81 | Some things were deliberately kept simple. E.g. if you have many routes, or need to [configure the router](api-routing?id=routing-options), you might want to extract the routing bits from the `config.ts` module into their own file. 82 | 83 | Also, for simplicity, in this example the routes just resolve to simple string literals like `'home'`. If your app doesn't have many routes and only a single router outlet, this will do just fine. Should you want to add additional protection from typos as your app grows, and improve support for refactoring, consider using string `enum`s or `const`s instead. -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | Using Rdx is easy. Here's how to get started. 4 | 5 | NOTE: This isn't intended to show you how to build and develop an app in general, only the specifics of how to use Rdx. It assumes you know how to use tools such as [npm], [TypeScript](https://www.typescriptlang.org/), [rollup](https://rollupjs.org/) etc... But for a complete ready-made example, see the [demo project](https://github.com/CaptainCodeman/rdx-demo/) 6 | 7 | ## Install Package 8 | 9 | Rdx is available as a package on [npm] for use with a bundler / build tool. You'll want to install the package and add it to your projects dependencies: 10 | 11 | ```bash 12 | npm install @captaincodeman/rdx 13 | ``` 14 | 15 | ## Define Models 16 | 17 | In Redux you often create different "branches" of state and combine them using the `combineReducers` function. In Rdx we call these branches `models`. We'll create a model for a basic counter to demonstrate how simple it is. 18 | 19 | ### src/state/models/counter.ts 20 | 21 | We use the `createModel` function, which accepts the initial default state for the model and the reducer methods that can "mutate" the state. (Technically, instead of mutating the state, a new state is returned.) Thanks to TypeScript, it is automatically checked that the state type used in each reducer method matches the state type of the model. That state can be a simple value (e.g. a number) or an object with nested properties and arrays. Reducer methods can include an optional parameter. Internally that parameter becomes the `payload` in an action, but you probably won't ever need to use actions directly when using Rdx. 22 | 23 | ```ts 24 | import { createModel } from '@captaincodeman/rdx' 25 | 26 | export const counter = createModel({ 27 | state: 0, 28 | 29 | reducers: { 30 | increment(state) { 31 | return state + 1 32 | }, 33 | 34 | decrement(state) { 35 | return state - 1 36 | }, 37 | 38 | add(state, value: number) { 39 | return state + value 40 | }, 41 | 42 | subtract(state, value: number) { 43 | return state - value 44 | } 45 | } 46 | }) 47 | ``` 48 | 49 | NOTE: TypeScript inference removes the need to define the state type on each function, even with strict type checks enabled. In this example, it _knows_ that `state` is a number: 50 | 51 | ![](reducer-state-inference.png) 52 | 53 | This can provide intellisense / development-time checks of your code if usage of the types doesn't make sense: 54 | 55 | ![](reducer-state-typing.png) 56 | 57 | All the reducers for a model work on the same state. If you try to define a reducer with a different state type it will warn you that there is a mis-match: 58 | 59 | ![](reducer-state-mismatch.png) 60 | 61 | ### src/state/models/index.ts 62 | 63 | We want to be able to easily import all the models that we add, so we re-export them in an `index.ts` file. As we add additional models, we'll just need to reference them in this file to have them be part of the store. 64 | 65 | ```ts 66 | export { counter } from './counter' 67 | ``` 68 | 69 | ## Create Store 70 | 71 | To create a basic store we use the `createStore` function, passing it a configuration object. The only required property is the `models` that we want to use. 72 | 73 | ```ts 74 | import { createStore } from '@captaincodeman/rdx' 75 | import * as models from './models' 76 | 77 | export const store = createStore({ models }) 78 | ``` 79 | 80 | ## Using the Store 81 | 82 | That's _all_ that is required for a basic store - much simpler than Redux, eh?! 83 | 84 | Rdx combines the state of all the models into one root state. You can refer to this from the store: 85 | 86 | ```ts 87 | const val = store.state.counter 88 | ``` 89 | 90 | It also generates action creators for all the models reducer functions which are available from the store dispatch method: 91 | 92 | ```ts 93 | store.dispatch.counter.increment(5) 94 | ``` 95 | 96 | But there's a lot more to Rdx, so checkout the [advanced usage](advanced) for a more complete walk-through of functionality. 97 | 98 | [npm]: https://www.npmjs.com/ -------------------------------------------------------------------------------- /docs/recipe-auth.md: -------------------------------------------------------------------------------- 1 | # Firebase Auth 2 | 3 | Most apps require some kind of authentication to identify the user and allow them secure access to their own data. 4 | 5 | [Firebase Auth](https://firebase.google.com/products/auth) is a powerful and free auth service from Google that supports multiple auth providers (Google, Firebase, Twitter, Microsoft, Github, Apple, Yahoo and more) as well as other auth methods such as email + password, email only (passwordless), phone, and anonymous accounts. 6 | 7 | While Firebase does provide a ready-made UI library as part of auth, it is fairly large and doesn't work well with modern web component based apps. As with many other things, there are benefits to integrating the auth state into your state store, as other models will often want to respond to auth status changes or make use of auth data to securely fetch data. 8 | 9 | ## Auth Model 10 | 11 | Rdx, of course, makes it easy. Here's a simple model that can be used to integrate Firebase Auth into your store. Other models can then choose to make use of the `auth/signedIn` and `auth/signedOut` actions for their own data management. 12 | 13 | ```ts 14 | import { createModel } from '@captaincodeman/rdx' 15 | import { State, Store } from '../store' 16 | import { auth } from '../firebase' 17 | 18 | export type User = import('firebase').User 19 | 20 | export interface AuthState { 21 | user: User | null 22 | statusKnown: boolean 23 | error: string 24 | } 25 | 26 | export default createModel({ 27 | state: { 28 | user: null, 29 | statusKnown: false, 30 | error: '', 31 | }, 32 | 33 | reducers: { 34 | signedIn(state, user: User) { 35 | return { ...state, user, statusKnown: true } 36 | }, 37 | 38 | signedOut(state) { 39 | return { ...state, user: null, statusKnown: true } 40 | }, 41 | 42 | failed(state, error: string) { 43 | return { ...state, error } 44 | } 45 | }, 46 | 47 | effects(store: Store) { 48 | const dispatch = store.getDispatch() 49 | 50 | return { 51 | // listen to firebase auth state changes and reflect in state 52 | async init() { 53 | auth.onAuthStateChanged(async user => { 54 | if (user) { 55 | dispatch.auth.signedIn(user) 56 | } else { 57 | dispatch.auth.signedOut() 58 | } 59 | }) 60 | }, 61 | 62 | // provide whatever signin methods you want to support 63 | async signinProvider(name: string) { 64 | try { 65 | const provider = providerFromName(name) 66 | await auth.signInWithRedirect(provider) 67 | } catch (err) { 68 | dispatch.auth.failed(err.message) 69 | } 70 | }, 71 | 72 | async signinEmailPassword(payload: { email: string, password: string }) { 73 | try { 74 | await auth.signInWithEmailAndPassword(payload.email, payload.password) 75 | } catch (err) { 76 | dispatch.auth.failed(err.message) 77 | } 78 | }, 79 | 80 | // provide signout method 81 | async signout() { 82 | await auth.signOut() 83 | }, 84 | } 85 | } 86 | }) 87 | 88 | function providerFromName(name: string) { 89 | // add whatever firebase auth providers are supported by the app 90 | switch (name) { 91 | case 'google': return new window.firebase.auth.GoogleAuthProvider() 92 | case 'facebook': return new window.firebase.auth.FacebookAuthProvider() 93 | case 'twitter': return new window.firebase.auth.TwitterAuthProvider() 94 | default: throw new Error(`unknown provider ${name}`) 95 | } 96 | } 97 | ``` 98 | 99 | Note the imported `auth` is the configured and loaded Firebase auth object as per the SDK examples (not shown here). It's also possible to lazy-load the firebase packages as they are fairly large and making them asynchronous can speed up the initial rendering of your app, but when startup time and total JS bundle size is a concern be sure to checkout the [firebase-auth-lite](https://github.com/samuelgozi/firebase-auth-lite) package for an excellent and much smaller alternative - the code is very easy to adapt and it makes a perfect companion to the tiny size approach of Rdx. 100 | 101 | ## Auth Status UI 102 | 103 | The UI can easily show the auth status to the user with a web component connected to the store. This example is designed to fit in the top of a material design drawer: 104 | 105 | **anonymous state** 106 | 107 | ![anonymous state](auth-anonymous.png) 108 | 109 | **authenticated state** 110 | 111 | ![authenticated state](auth-authenticated.png) 112 | 113 | ```ts 114 | import { property, html, customElement, css } from 'lit-element' 115 | import { Connected, User, State, AuthSelectors } from './connected' 116 | import { sharedStyles } from './shared-styles' 117 | 118 | @customElement('auth-status') 119 | export class AuthStatusElement extends Connected { 120 | @property({ type: Boolean }) 121 | statusKnown: boolean 122 | 123 | @property({ type: Object }) 124 | user: User 125 | 126 | mapState(state: State) { 127 | return { 128 | statusKnown: state.auth.statusKnown, 129 | user: state.auth.user, 130 | } 131 | } 132 | 133 | shouldUpdate() { 134 | return this.statusKnown 135 | } 136 | 137 | render() { 138 | return this.user === null 139 | // show visitor status if not authenticated, and link to /signin page 140 | ? html` 141 | 142 | 143 | 144 | 145 | 146 |
147 |

Visitor

148 |

Sign-in to use app …

149 |
150 | ` 151 | // show user status if authenticated, and link to /account page 152 | : html` 153 | 154 |
155 |

${this.user.displayName}

156 |

${this.user.email}

157 |
158 | ` 159 | } 160 | 161 | static get styles() { 162 | return [ 163 | sharedStyles, 164 | css` 165 | :host { 166 | width: 255px; 167 | height: 56px; 168 | padding: 3px; 169 | display: flex; 170 | box-sizing: border-box; 171 | contain: strict; 172 | } 173 | 174 | img, svg { 175 | width: 50px; 176 | height: 50px; 177 | margin-right: 5px; 178 | color: #444; 179 | } 180 | 181 | svg path { 182 | fill: #444; 183 | } 184 | 185 | h2 { 186 | font-size: 18px; 187 | line-height: 18px; 188 | font-weight: normal; 189 | margin: 8px 0 6px 0; 190 | color: #222; 191 | } 192 | 193 | p { 194 | font-size: 12px; 195 | line-height: 12px; 196 | margin: 6px 0; 197 | color: #666; 198 | } 199 | 200 | div { 201 | overflow: hidden; 202 | } 203 | 204 | h2, p { 205 | text-overflow: ellipsis; 206 | overflow: hidden; 207 | white-space: nowrap; 208 | } 209 | 210 | @media (min-width: 600px) { 211 | :host { 212 | height: 64px; 213 | padding: 8px; 214 | } 215 | } 216 | `, 217 | ] 218 | } 219 | } 220 | 221 | declare global { 222 | interface HTMLElementTagNameMap { 223 | 'auth-status': AuthStatusElement 224 | } 225 | } 226 | ``` 227 | 228 | The sign-in view simply has to dispatch the appropriate action in response to a button click to trigger the sign-in process, e.g. to sign-in with Google: 229 | 230 | ```ts 231 | dispatch.auth.signinProvider('google') 232 | ``` 233 | 234 | It can also display any auth failure messages from the state. -------------------------------------------------------------------------------- /docs/recipe-fetch.md: -------------------------------------------------------------------------------- 1 | # REST Data Fetching 2 | 3 | The canonical example for a state store (beyond a simple "counter") is to show how asynchronous operations are converted into synchronous actions. This is what makes things "predictable" - you have a single place where you have to deal with awaiting data and don't have to deal with it in your views or templating system. 4 | 5 | Let's reproduce the [Advanced Reddit API Example](https://redux.js.org/advanced/example-reddit-api) from Redux. 6 | 7 | ```ts 8 | import { createModel } from '@captaincodeman/rdx' 9 | import { State, Store } from '../store' 10 | 11 | export interface SubredditState { 12 | isFetching: boolean 13 | didInvalidate: boolean 14 | items: any[] 15 | lastUpdated: number 16 | } 17 | 18 | const blankSubredditState: SubredditState = { 19 | isFetching: false, 20 | didInvalidate: false, 21 | items: [], 22 | lastUpdated: 0, 23 | } 24 | 25 | export interface RedditState { 26 | postsBySubreddit: { [subreddit: string]: SubredditState } 27 | selectedSubreddit: string 28 | } 29 | 30 | export default createModel({ 31 | state: { 32 | postsBySubreddit: { }, 33 | selectedSubreddit: 'reactjs', 34 | }, 35 | 36 | reducers: { 37 | selectSubreddit(state, subreddit: string) { 38 | return { ...state, 39 | selectedSubreddit: subreddit, 40 | } 41 | }, 42 | 43 | invalidateSubreddit(state, subreddit: string) { 44 | return { ...state, 45 | postsBySubreddit: {...state.postsBySubreddit, 46 | [subreddit]: { ...state.postsBySubreddit[subreddit] || blankSubredditState, 47 | didInvalidate: true, 48 | }, 49 | }, 50 | } 51 | }, 52 | 53 | requestPosts(state, subreddit: string) { 54 | return { ...state, 55 | postsBySubreddit: { ...state.postsBySubreddit, 56 | [subreddit]: { ...state.postsBySubreddit[subreddit] || blankSubredditState, 57 | isFetching: true, 58 | didInvalidate: false, 59 | }, 60 | }, 61 | } 62 | }, 63 | 64 | receivePosts(state, payload: { subreddit: string, posts: any[], receivedAt: number }) { 65 | return { ...state, 66 | postsBySubreddit: { ...state.postsBySubreddit, 67 | [payload.subreddit]: { ...state[payload.subreddit] || blankSubredditState, 68 | isFetching: false, 69 | didInvalidate: false, 70 | items: payload.posts, 71 | lastUpdated: payload.receivedAt, 72 | }, 73 | }, 74 | } 75 | }, 76 | }, 77 | 78 | effects(store: Store) { 79 | const dispatch = store.getDispatch() 80 | 81 | function shouldFetchPosts(subreddit: string) { 82 | const state = store.getState() 83 | const posts = state.postsBySubreddit[subreddit] 84 | 85 | if (!posts) { 86 | return true 87 | } else if (posts.isFetching) { 88 | return false 89 | } else { 90 | return posts.didInvalidate 91 | } 92 | } 93 | 94 | return { 95 | async fetchPosts(subreddit: string) { 96 | dispatch.reddit.requestPosts(subreddit) 97 | const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`) 98 | const json = await response.json() 99 | dispatch.reddit.receivePosts({ 100 | subreddit, 101 | posts: json.data.children.map(child => child.data), 102 | receivedAt: Date.now(), 103 | }) 104 | }, 105 | 106 | async fetchPostsIfNeeded(subreddit: string) { 107 | if (shouldFetchPosts(subreddit)) { 108 | dispatch.reddit.fetchPosts(subreddit) 109 | } 110 | } 111 | } 112 | } 113 | }) 114 | ``` -------------------------------------------------------------------------------- /docs/recipe-firestore.md: -------------------------------------------------------------------------------- 1 | # Firestore Subscriptions 2 | 3 | If you haven't used it, [Firestore](https://firebase.google.com/products/firestore) is a NoSQL database that is accessible over the web, integrates with [Firebase Auth](https://firebase.google.com/products/auth) for security, and also provides offline support as well as live-updating capabilities. As data in the database changes, any connected clients can update their views to reflect that new data, making collaborative applications easier than ever. 4 | 5 | To take advantage of this apps need to setup a _subscription_ or _listener_ that can be notified when data changes. This is different to a traditional REST API, where you may make a single request for data when the user loads a view that uses it, and you only update that view if they specifically request it be refreshed (or you do it the next time that view loads). 6 | 7 | A Firestore app can be simpler to develop than one using REST, provide more robustness (you get automatic retries if fetching data fails or the client has an intermittent network connection), but requires a different approach. 8 | 9 | The main difference is that instead of one-off data fetches we're going to have long-lived subscriptions. While the syntax for initiating these listeners is fairly straightforward, it's often less obvious how to manage them efficiently. We may want these subscriptions to live over several views of our app, other times a subscription is specific to a single view, but the challenge is not how to create them, it's more how and where to remove them. 10 | 11 | As we discussed in the [Advanced / Routing / Models Integration](advanced?id=models-integration) section, coupling your data-fetching to UI components can be a mistake and considered an anti-pattern, but becomes especially problematic when you have to manage subscriptions that have to live across several components. While you could setup and tear-down the subscriptions with each view, this can result in increased reads (and therefore costs to your app). 12 | 13 | As always, Rdx is here to help you. Let's see how we can utilize a Firestore subscription within our model: 14 | 15 | ```ts 16 | import { createModel, RoutingState } from '@captaincodeman/rdx' 17 | import { Store, State } from '../store' 18 | import { Todo } from '../schema' 19 | import { firestore } from '../firebase' 20 | 21 | export interface TodosState { 22 | entities: { [key: string]: Todo } 23 | selected: string 24 | } 25 | 26 | export default createModel({ 27 | state: { 28 | entities: { }, 29 | selected: '', 30 | }, 31 | 32 | reducers: { 33 | // sets the selected property for the UI to display 34 | 'routing/change'(state, payload: RoutingState) { 35 | switch (payload.page) { 36 | case 'todos-detail-view': 37 | return { ...state, selected: payload.params.id } 38 | default: 39 | return state 40 | } 41 | }, 42 | 43 | // received adds any received todo items into the entity map 44 | // replacing any that are already there 45 | received(state, todos: Todo[]) { 46 | return { 47 | ...state, 48 | entities: todos.reduce((map, todo) => { 49 | map[todo.id] = todo 50 | return map 51 | }, state.entities), 52 | } 53 | }, 54 | }, 55 | 56 | effects(store: Store) { 57 | // capture the store dispatch method 58 | const dispatch = store.getDispatch() 59 | 60 | // listListener will be used to keep track of the listener 61 | // for the list of todo items. Because we want to unsubscribe 62 | // from multiple places we use a function that can check for, 63 | // call and nullify any active listener 64 | let listListener: Function 65 | 66 | function unsubscribeFromList() { 67 | if (listListener) { 68 | listListener() 69 | listListener = null 70 | } 71 | } 72 | 73 | // setup the subscription to the list of todo items for the user 74 | function subscribeToList() { 75 | // query the todos collection 76 | const query = firestore.collection('todos') 77 | 78 | // the onSnapshot listener will be called anytime the data is 79 | // updated (including the initial query results) 80 | listListener = query.onSnapshot(snapshot => { 81 | // transform the firestore data into a Todo array, with the ID 82 | const todos = snapshot.docs.map(doc => ({ ...doc.data(), id: doc.id })) 83 | 84 | // update the store so it contains updated todos 85 | dispatch.todos.received(todos) 86 | }) 87 | } 88 | 89 | // a subscription for an individual item works in the same way 90 | let itemListener: Function 91 | 92 | function unsubscribeFromItem() { 93 | if (itemListener) { 94 | itemListener() 95 | itemListener = null 96 | } 97 | } 98 | 99 | // setup the subscription to a single todo item 100 | function subscribeToItem(id: string) { 101 | const doc = firestore.doc(`todos/${id}`) 102 | 103 | // the onSnapshot listener watches a single item 104 | itemListener = doc.onSnapshot(doc => { 105 | // we create an array containing the single item 106 | // so we can re-use the same reducer as for the list 107 | const todos = [{ ...doc.data(), id: doc.id }] 108 | 109 | // update the store so it contains the updated todo 110 | dispatch.todos.received(todos) 111 | }) 112 | } 113 | 114 | return { 115 | // we change subscriptions when the route changes 116 | async 'routing/change'(payload: RoutingState) { 117 | switch (payload.page) { 118 | case 'todos-view': 119 | // when navigating to the todos list page we 120 | // unsubscribe from any individual listeners 121 | // and subscribe to the complete list 122 | unsubscribeFromItem() 123 | subscribeToList() 124 | break 125 | case 'todos-detail-view': 126 | // if we're already subscribed to the complete 127 | // list then we don't need to subscribe to the 128 | // individual item as updates will already be 129 | // included in the list. If a user is likely to 130 | // navigate between list and item views this can 131 | // be more efficient and cost effective 132 | if (!unsubscribeFromList) { 133 | // if we're not already subscribed to the list 134 | // we unsubscribe from any individual item and 135 | // setup a subscription to the new selected one 136 | unsubscribeFromItem() 137 | subscribeToItem(payload.params.id) 138 | } 139 | break 140 | default: 141 | // assuming any other route isn't interested in 142 | // the todo list or individual items, we can turn 143 | // off the subscriptions here 144 | // NOTE: the data is still in the state store, it 145 | // just won't be kept updated ... but will allow 146 | // an initial 'cached' view to be shown before it 147 | // is refreshed from the server whenever the user 148 | // navigates back to one of the todo views 149 | unsubscribeFromList() 150 | unsubscribeFromItem() 151 | break 152 | } 153 | }, 154 | } 155 | }, 156 | }) 157 | ``` 158 | 159 | It can be easier to create a separate class that you update by passing it the store state so it can manage which subscriptions need to be kept active. That way, all the firestore code is in one place, as a Data Access Layer. 160 | 161 | The models then subscribe to this subscription manager to be notified of data changes that need to be reflected in the store. 162 | 163 | There are other nuances to Firestore, some are not obvious. While it appears that a subscription to a list returns the _entire_ item list each time, not all that data is coming from the server - the Firestore SDK provides it's own internal cache (also used for offline use) and just provides you with the aggregated view of unchanged plus updated data in a way that makes it easier to use the same code for both the initial view and subsequent updates. You _can_ create more granular snapshot listeners that specifically check for different actions (data being added, updated or deleted) and these _may_ be something your reducers can handle in a more efficient way. -------------------------------------------------------------------------------- /docs/recipe-routing.md: -------------------------------------------------------------------------------- 1 | # Routing Recipes 2 | 3 | Here are some typical problems and suggested solutions. As is often the case, the recipes show just _one_ way to do it, not _the_ way. While we think, our recipes are solid, your situation may require and justify a different approach. 4 | 5 | In order to keep the recipes short and to the point, here are other routing related parts of the documentation you may find relevant: 6 | 7 | - Check out [how to set-up the routing plugin](plugin-routing?id=example). 8 | - Find a very simple [router outlet for lit-element based applications](advanced?id=router-outlet). 9 | - Learn how to [integrate models](advanced?id=models-integration) to facilitate early loading of app data. 10 | - Read the fine print in the [routing api](api-routing). 11 | 12 | ## Recipe 1: Multiple Router Outlets 13 | 14 | Sometimes the hierarchy of your UI components does not follow the hierarchy of your data architecture. E.g. if you use `` for application layout, you might not just want to change the main view of the application (the part under the top bar), but you might also want to change the title, or the navigation icon, or the "action items" (toolbar icons) based on the view you are displaying, if you selected an item in a list, etc. 15 | 16 | Luckily, by using Rdx, you have already provided a "single source of truth" for application state, and by using the routing plugin, this state includes routing information as well. So, all you need to do, is tap into that state information in more than one component, making it a router outlet. 17 | 18 | _NOTE:_ All the router outlets are based on the [example in the advanced guide](advanced?id=router-outlet). The explanations will not be repeated here. 19 | 20 | ### src/ui/app-shell.ts 21 | 22 | This is the top level web component, that contains the entire application. In this case it uses a `` element for the application layout. 23 | 24 | ```ts 25 | import { LitElement, customElement, html, css } from 'lit-element' 26 | import '@material/mwc-top-app-bar-fixed' 27 | import './app-bar-action-items' 28 | import './app-bar-nav-icon' 29 | import './app-bar-title' 30 | import './app-router' 31 | 32 | @customElement('app-shell') 33 | export class AppShellElement extends LitElement { 34 | render() { 35 | return html` 36 | 37 | 38 | 39 | 40 | ` 41 | } 42 | 43 | static get styles() { 44 | return css` 45 | :host { 46 | display: block; 47 | height: 100%; 48 | } 49 | mwc-top-app-bar-fixed { 50 | height: 100%; 51 | } 52 | ` 53 | } 54 | } 55 | ``` 56 | 57 | ### src/ui/app-bar-action-items.ts 58 | 59 | ```ts 60 | import { TemplateResult, customElement, html, property } from 'lit-element' 61 | import { RoutingState } from '@captaincodeman/rdx' 62 | import '@material/mwc-icon-button' 63 | import { Connected, State } from '../connected' 64 | 65 | const contentByPage = { 66 | 'todo-list': html``, 67 | 'todo-details': html``, 68 | } 69 | 70 | @customElement('app-bar-action-items') 71 | class AppBarActionItemsElement extends Connected { 72 | private page: string 73 | 74 | @property({attribute: false}) 75 | content: TemplateResult 76 | 77 | mapState(state: State) { 78 | return { 79 | route: state.routing 80 | } 81 | } 82 | 83 | set route(val: RoutingState) { 84 | if (val.page !== this.page) { 85 | this.page = val.page 86 | this.content = contentByPage[this.page] || '' 87 | } 88 | } 89 | 90 | render() { 91 | return this.content 92 | } 93 | } 94 | ``` 95 | 96 | ### src/ui/app-bar-nav-icon.ts 97 | 98 | ```ts 99 | import { TemplateResult, customElement, html, property } from 'lit-element' 100 | import { nothing } from 'lit-html' 101 | import { RoutingState } from '@captaincodeman/rdx' 102 | import '@material/mwc-icon-button' 103 | import { Connected, State } from '../connected' 104 | 105 | const contentByPage = { 106 | 'home': html``, 107 | 'todo-list': html``, 108 | 'todo-details': html``, 109 | 'not-found': html``, 110 | } 111 | 112 | @customElement('app-bar-nav-icon') 113 | class AppBarNavIconElement extends Connected { 114 | private page: string 115 | 116 | @property({attribute: false}) 117 | content: string 118 | 119 | mapState(state: State) { 120 | return { 121 | route: state.routing 122 | } 123 | } 124 | 125 | set route(val: RoutingState) { 126 | if (val.page !== this.page) { 127 | this.page = val.page 128 | this.content = contentByPage[this.page] || '' 129 | } 130 | } 131 | 132 | render() { 133 | return this.content 134 | } 135 | } 136 | ``` 137 | 138 | ### src/ui/app-bar-title.ts 139 | 140 | ```ts 141 | import { TemplateResult, customElement, html, property } from 'lit-element' 142 | import { RoutingState } from '@captaincodeman/rdx' 143 | import '@material/mwc-icon-button' 144 | import { Connected, State } from '../connected' 145 | 146 | const contentByPage = { 147 | 'home': 'My App', 148 | 'todo-list': 'List', 149 | 'todo-details': 'Details', 150 | } 151 | 152 | @customElement('app-bar-title') 153 | class AppBarTitleElement extends Connected { 154 | private page: string 155 | 156 | @property({attribute: false}) 157 | content: TemplateResult 158 | 159 | mapState(state: State) { 160 | return { 161 | route: state.routing 162 | } 163 | } 164 | 165 | set route(val: RoutingState) { 166 | if (val.page !== this.page) { 167 | this.page = val.page 168 | this.content = contentByPage[this.page] || '' 169 | } 170 | } 171 | 172 | render() { 173 | return this.content 174 | } 175 | } 176 | ``` 177 | 178 | ### src/ui/app-router.ts 179 | 180 | This is exactly the same as the [router outlet for lit-element based applications](advanced?id=router-outlet). 181 | 182 | ## Recipe 2: Lazy Loading Views 183 | 184 | The term "lazy loading" refers to the concept, that you don't provide all source code of the entire app to the browser in one go, but rather just provide the absolutely required minimum amount (aka be lazy), and then, if the user uses certain parts, go and load that afterwards. 185 | If your application could just load the bare minimum on start-up, that should speed up application start-up time (and thereby improve your app's lighthouse score). 186 | 187 | In order to lazily load views you need to change the app router a bit. The lazy loading itself is achieved through using [dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) (aka "import function"). 188 | 189 | _NOTE:_ Dynamic imports still require the module specifier to be a static string literal. Providing a string variable is **not supported**, so you need to hard code which module to import for which route. 190 | 191 | ### src/ui/lazy-app-router.ts 192 | 193 | ```ts 194 | import { customElement, html, property } from 'lit-element' 195 | import { RoutingState } from '@captaincodeman/rdx' 196 | import '@material/mwc-icon-button' 197 | import { Connected, State } from '../connected' 198 | // view-home and view-not-found are fundamental (and small) 199 | // they are eagerly loaded in this example 200 | import './view-home' 201 | import './view-not-found' 202 | 203 | @customElement('lazy-app-router') 204 | class LazyAppRouterElement extends Connected { 205 | private page: string 206 | 207 | @property({attribute: false}) 208 | content: TemplateResult 209 | 210 | mapState(state: State) { 211 | return { 212 | route: state.routing 213 | } 214 | } 215 | 216 | set route(val: RoutingState) { 217 | if (val.page !== this.page) { 218 | this.page = val.page 219 | switch (this.page) { 220 | case 'home': 221 | // view-home was eagerly loaded, can be used right away... 222 | this.content = html`` 223 | break 224 | case 'not-found': 225 | // view-not-found was eagerly loaded, can be used right away... 226 | this.content = html`` 227 | break 228 | // dynamic imports provide a Promise based API 229 | case 'todo-list': 230 | import('./view-todo-list').then(() => { this.content = html`` }) 231 | break 232 | case 'todo-details': 233 | import('./view-todo-details').then(() => { this.content = html`` }) 234 | break 235 | } 236 | } 237 | } 238 | 239 | render() { 240 | return this.content 241 | } 242 | } 243 | ``` -------------------------------------------------------------------------------- /docs/reducer-state-inference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/reducer-state-inference.png -------------------------------------------------------------------------------- /docs/reducer-state-mismatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/reducer-state-mismatch.png -------------------------------------------------------------------------------- /docs/reducer-state-typing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/reducer-state-typing.png -------------------------------------------------------------------------------- /docs/strongly-typed-dispatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/strongly-typed-dispatch.png -------------------------------------------------------------------------------- /docs/strongly-typed-state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainCodeman/rdx/f16a586c8a2056a7466c11935bc2b04ef98d00b5/docs/strongly-typed-state.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@captaincodeman/rdx", 3 | "version": "1.0.0-rc.9", 4 | "description": "Small state library. Like Redux, but smaller", 5 | "type": "module", 6 | "module": "lib/index.js", 7 | "main": "lib/index.js", 8 | "types": "typings/index.d.ts", 9 | "sideEffects": false, 10 | "files": [ 11 | "lib", 12 | "typings" 13 | ], 14 | "exports": { 15 | ".": "./lib/index.js" 16 | }, 17 | "scripts": { 18 | "docs": "docsify serve ./docs", 19 | "build": "rollup -c", 20 | "build:watch": "rollup -c -w", 21 | "test": "package-check", 22 | "test:watch": "mocha -w", 23 | "test:types": "dtslint ./test/types", 24 | "prepublishOnly": "npm run build", 25 | "start": "node server.js" 26 | }, 27 | "author": "Simon Green (https://github.com/captaincodeman)", 28 | "license": "ISC", 29 | "homepage": "https://captaincodeman.github.io/rdx/", 30 | "repository": { 31 | "type": "git", 32 | "url": "git://github.com/CaptainCodeman/rdx" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/CaptainCodeman/rdx/issues" 36 | }, 37 | "keywords": [ 38 | "lightweight", 39 | "tiny", 40 | "state", 41 | "client", 42 | "store", 43 | "redux", 44 | "immutable", 45 | "reducer" 46 | ], 47 | "devDependencies": { 48 | "@rollup/plugin-typescript": "^6.0.0", 49 | "@skypack/package-check": "^0.1.0", 50 | "browser-sync": "^2.26.13", 51 | "chai": "^4.2.0", 52 | "compression": "^1.7.4", 53 | "conditional-type-checks": "^1.0.5", 54 | "docsify-cli": "^4.4.1", 55 | "dtslint": "^4.0.4", 56 | "mocha": "^8.2.0", 57 | "redux": "^4.0.5", 58 | "rollup": "^2.32.1", 59 | "rollup-plugin-size": "^0.2.2", 60 | "rollup-plugin-terser": "^7.0.2", 61 | "tslib": "^2.0.3", 62 | "typescript": "^4.0.3" 63 | }, 64 | "dependencies": { 65 | "@captaincodeman/router": "^1.0.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![minified](https://badgen.net/bundlephobia/min/@captaincodeman/rdx) 2 | ![minzipped](https://badgen.net/bundlephobia/minzip/@captaincodeman/rdx) 3 | 4 | # Rdx 5 | 6 | Like Redux, but smaller ... 7 | 8 | [docs](https://captaincodeman.github.io/rdx) 9 | 10 | This is a simple immutable state store along the lines of Redux but significantly smaller - it helps to build apps with super-tiny JavaScript payloads. It provides all the basic features for creating a client-side app including: 11 | 12 | * Redux-like state store (actions / reducers / middleware) 13 | * Root reducer utility function (combineReducers) 14 | * Handling of async actions (aka 'thunks') 15 | * Mixin to connect custom elements to the store (map state to properties and events to store dispatch) 16 | 17 | Total size: 1.47 Kb minified / 640 bytes gzipped 18 | 19 | With additional enhancements: 20 | 21 | * Redux DevTools integration for debug and time-travel 22 | * State hydration & persistence with action filtering, debounce and pluggable storage + serialization (defaults to localStorage and JSON) 23 | 24 | Total size: 2.19 Kb minified / 969 bytes gzipped 25 | 26 | See a fully functional [example app](https://github.com/CaptainCodeman/rdx-example) built using this. 27 | 28 | ## Compatibility 29 | 30 | While the aim isn't to be 100% compatible with Redux, it can work with the Redux DevTools Extension and there is an _experimental_ `compat` module to simulate the Redux API and adapt existing Redux middleware. 31 | 32 | ## Usage 33 | 34 | To create your state store: 35 | 36 | ```ts 37 | import { Store, combineReducers, connect, thunk, persist, devtools} from '@captaincodeman/rdx' 38 | 39 | // a very simple reducer state 40 | const counter = (state = 0, action) => { 41 | switch (action.type) { 42 | case 'counter/inc': 43 | return state + 1 44 | case 'counter/dec': 45 | return state - 1 46 | default: 47 | return state 48 | } 49 | } 50 | 51 | // more complex state for data loading 52 | const todos = (state = { 53 | data: {}, 54 | loading: false, 55 | err: '', 56 | }, action) => { 57 | switch (action.type) { 58 | case 'todos/request': 59 | return {...state, loading: true } 60 | case 'todos/receive': 61 | return {...state, loading: false, data: action.payload } 62 | case 'todos/failure': 63 | return {...state, loading: false, err: action.payload } 64 | default: 65 | return state 66 | } 67 | } 68 | 69 | // create root reducer 70 | const reducer = combineReducers({ counter, todos }) 71 | 72 | // initial state could come from SSR string 73 | const initial = undefined 74 | 75 | // create the store - this will persist state to localStorage, 76 | // support async actions (thunks) and allow time-travel debug 77 | // using the Redux devtools extension 78 | const store = devtools(persist(thunk(new Store(initial, reducer)))) 79 | ``` 80 | 81 | ## Approach 82 | 83 | I've tried to re-think a few things to keep the size down because a lot of what Redux does is very clever but not necessary for what I need. The flexible "currying everywhere" approach to configuration may be very extensible but is more complex than required and confusing to use. The checks, warnings and error messages are not required when using TypeScript and in fact a simpler configuration reduces the requirement for it anyway. 84 | 85 | Wherever possible we can also take advantage of existing web platform features instead of including additional JS code that simply replicates them. The ability to subscribe to something to receive events for instance is very common in the browser so for notification of state changes and dispatched actions we rely on the inbuilt [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget). 86 | 87 | Unfortunately, the `EventTarget` _constructor()_ is not currently supported on WebKit so if you want to support Safari users, an additional small (589 byte) polyfill is needed which can be loaded only when required: 88 | 89 | ```html 90 | 91 | ``` 92 | 93 | Once [support is added](https://bugs.webkit.org/show_bug.cgi?id=174313) this polyfill can be removed and will stop loading automatically. 94 | 95 | ## Models 96 | 97 | But it's not "just" a Redux implementation, it makes it much easier to develop your app. 98 | 99 | I really liked the approach of [redux-rematch](https://rematch.github.io/rematch/) to reduce the boilerplate required when using Redux. For more background and the motivation behind this approach 100 | see [redesigning-redux](https://hackernoon.com/redesigning-redux-b2baee8b8a38). 101 | 102 | This brings that same approach to Rdx and allows you to define your state models in a very small and compact way, without verbose boilerplate code by providing helpers to create the store for you and plugins to add common functionality such as routing. 103 | 104 | See a [live example](https://captaincodeman.github.io/rdx-example/) or checkout the [source code](https://github.com/CaptainCodeman/rdx-example). The usage example below is based on this example. 105 | 106 | ### createStore 107 | 108 | This helps create a store instance for you and wires up dispatch and async effects for yor models. It starts like this, we'll see where the `models` come from later: 109 | 110 | #### store/index.ts 111 | 112 | ```ts 113 | import { createStore } from '@captaincodeman/rdx' 114 | import * as models from './models' 115 | 116 | export const store = createStore({ models }) 117 | ``` 118 | 119 | The store that is created is a regular Rdx store with some additional, auto-generated actionCreator-type methods added to the `dispatch` method to make using the store easier ... we'll get to those later. 120 | 121 | ### Plugins and Extensions 122 | 123 | If we require additional store functionality, that can be added by wrapping the store or providing plugins. Lets add state persistence and hydration using `localStorage` and also wire up the Redux DevTools extension (both provided by Rdx) plus add routing using a plugin provided by this package. 124 | 125 | First, we'll define our store configuration, including routes, in a separate file using a [tiny client-side router package](https://github.com/CaptainCodeman/js-router): 126 | 127 | #### store/config.ts 128 | 129 | ```ts 130 | import createMatcher from '@captaincodeman/router' 131 | import { routingPlugin } from '@captaincodeman/rdx' 132 | import * as models from './models' 133 | 134 | const routes = { 135 | '/': 'home-view', 136 | '/todos': 'todos-view', 137 | '/todos/:id': 'todo-view', 138 | '/*': 'not-found', 139 | } 140 | 141 | const matcher = createMatcher(routes) 142 | const routing = routingPlugin(matcher) 143 | 144 | export const config = { models, plugins: { routing } } 145 | ``` 146 | 147 | We'll import the exported `config` and use the `createStore` helper to create an instance of the Rdx store and this time we'll decorate it with the `devtools` and `persist` enhancers that the `rdx` package provides so we get the integration with Redux DevTools plus state persistence using `localStorage`. It's only slightly more complex than the first example: 148 | 149 | #### store/index.ts 150 | 151 | ```ts 152 | import { createStore, StoreState, StoreDispatch, EffectStore } from '@captaincodeman/rdx' 153 | import { devtools, persist} from '@captaincodeman/rdx' 154 | import { config } from './config' 155 | 156 | export const store = devtools(persist(createStore(config))) 157 | 158 | export interface State extends StoreState {} 159 | export interface Dispatch extends StoreDispatch {} 160 | export interface Store extends EffectStore {} 161 | ``` 162 | 163 | Note the `State`, `Dispatch` and `Store` interfaces provide strongly-typed access to the store. 164 | 165 | ### createModel 166 | 167 | So what about the models that are imported? That's really where all the 'action' is or actions _are_, it's a Redux pun see ... oh, nevermind, anyway let's focus on those. All the models are in a separate `/models` module which simply re-exports and names the individual state branches. This makes it easy to manage as the state in your app grows. 168 | 169 | #### store/models/index.ts 170 | 171 | ```ts 172 | export { counter } from './counter' 173 | export { todos } from './todos' 174 | ``` 175 | 176 | The state branches are then defined in their own files. A simple counter state is, well, simple ... because why should it _need_ to be complicated? 177 | 178 | #### store/models/counter.ts 179 | 180 | ```ts 181 | import { createModel } from '@captaincodeman/rdx' 182 | 183 | export const counter = createModel({ 184 | state: { 185 | value: 0, 186 | }, 187 | reducers: { 188 | inc(state) { 189 | return { ...state, value: state.value + 1 }; 190 | }, 191 | add(state, payload: number) { 192 | return { ...state, value: state.value + payload }; 193 | }, 194 | }, 195 | }) 196 | ``` 197 | 198 | The state can be as simple or complex as needed. For this example we could have made the state be the numeric value directly, but that isn't typical in a real app. Likewise, the payload passed to a reducer method can be more than just a single value, it would be the same type of payload that an action typically has. Hmmn ... can you see where this is going? 199 | 200 | The `createModel` helper is really there just to aid typing. It not only defines the initial state but also infers the state type, so it doesn't need to be defined in each reducer function. Each reducer must accept the state as the first parameter and then an optional payload as a second parameter. Why this 'restriction'? Because these reducer methods are transformed into actionCreator-type functions that both _create_ and _dispatch_ an action in a single call. 201 | 202 | Take the `add` reducer method on the `counter` state model above. This is converted into a strongly typed method on the store dispatch which allows you to call strongly typed methods to dispatch actions such as: 203 | 204 | ```ts 205 | dispatch.counter.add(5) 206 | ``` 207 | 208 | To be clear - we're still using a state store and are dispatching actions that go through any middleware and eventually may hit the reducer, we are not just calling the reducer directly as it may appear. We still have immutable and predictable state, just without all the boilerplate code. 209 | 210 | The action type is created automatically based on the name of the model and the name of the reducer function, so the example above would cause an action to be dispatched with the type `counter/add` (which is the naming convention Redux now recommends). 211 | 212 | If we were using Redux we might have code that looks more like this: 213 | 214 | ```ts 215 | export enum CounterTypes { 216 | COUNTER_INC: 'COUNTER_INC' 217 | COUNTER_ADD: 'COUNTER_ADD' 218 | } 219 | 220 | export interface CounterInc { 221 | readonly type: COUNTER_INC 222 | } 223 | 224 | export interface CounterAdd { 225 | readonly type: COUNTER_ADD 226 | readonly payload: number 227 | } 228 | 229 | export type CounterActions = CounterInc | CounterAdd 230 | 231 | export const createCounterInc = () => { 232 | return { 233 | type: COUNTER_INC, 234 | } 235 | } 236 | 237 | export const createCounterAdd = (value: number) => { 238 | return { 239 | type: COUNTER_ADD, 240 | payload: value, 241 | } 242 | } 243 | 244 | export interface CounterState { 245 | value: number 246 | } 247 | 248 | const initialState: CounterState = { 249 | value: 0, 250 | }; 251 | 252 | export const counterReducer = (state: CounterState = initialState, action: CounterActions) => { 253 | switch (action.type) { 254 | case CounterTypes.COUNTER_INC: 255 | return { 256 | ...state, 257 | value: state.value + 1 258 | }; 259 | 260 | case CounterTypes.COUNTER_ADD: 261 | return { 262 | ...state, 263 | value: state.value + action.payload 264 | }; 265 | 266 | default: 267 | return state 268 | } 269 | } 270 | 271 | // to call: 272 | store.dispatch(createCounterAdd(2)) 273 | ``` 274 | 275 | How many times should we have to type 'counter', _really_? So many potential gotchas to make a mistake. That's just one simple state branch - imagine what happens when we have a large application and multiple actions in multiple state branches? This is where people might say Redux isn't worth it and is overkill - but what Redux _does_ is definitely worthwhile, it's just that it does it in a complex way. 276 | 277 | Yes, some of this is deliberately verbose to make the point and there are various helpers that can be used to reduce some of the pain points (at the cost of extra code), but Redux definitely has some overhead - it's not simple to use and the extra code doesn't really add any value and it becomes complex to work with as it's often spread across multiple files, sometimes even multiple folders. 278 | 279 | ### async Effects 280 | 281 | A counter is the simplest canonical example of a reducer. Often you need to have a combination of state and reducers plus some 'side-effects' - async functions can can be dispatched (thunks) or that can execute in response to the synchronous actions that go through the store, often as middleware. We have that covered! Oh, and there's no middleware to add, all the functionality is baked into the `createStore` that we saw earlier. 282 | 283 | Let's look at something more complex, the state for a 'todo' app which needs to handle async fetching of data from a REST API. We want to only fetch data when we don't already have it and what we need to fetch will depend on the route we're on - if we go from a list view to a single-item view, we don't need to fetch that single item as we already have it, but if our first view is the single item we want to fetch just that, and then fetch the list if we navigate in the other direction. 284 | 285 | Also, we want to be able to display loading state in the UI so we need to be able to indicate when we've requested data and when it's loaded. This is where the Redux approach shines - converting asynchronous changes to predictable and replayable synchonous state updates 286 | 287 | The state part of this example is just a more complex but still typical example of immutable, Redux-like, state. But as well as defining actions as reducers, we can also define effects. These can also be dispatched just like the reducer-generated actions, but they also act as hooks so that when an action has been dispatched, if an effect exists with the same name, that will be called automatically. 288 | 289 | #### store/models/todos.ts 290 | 291 | ```ts 292 | import { createModel, RoutingState } from '@captaincodeman/rdx'; 293 | import { Store } from '../store'; 294 | 295 | // we're going to use a test endpoint that provides some ready-made data 296 | const endpoint = 'https://jsonplaceholder.typicode.com/' 297 | 298 | // this is the shape of a single TODO item that the endpoint provides 299 | export interface Todo { 300 | userId: number 301 | id: number 302 | title: string 303 | completed: boolean 304 | } 305 | 306 | // this is the shape of our todos state in the store, having it strongly 307 | // typed saves some mistakes when we access or update it 308 | export interface TodosState { 309 | entities: { [key: number]: Todo } 310 | ids: number[] 311 | selected: number 312 | loading: boolean 313 | } 314 | 315 | export default createModel({ 316 | // our initial model state 317 | state: { 318 | entities: {}, 319 | ids: [], 320 | selected: 0, 321 | loading: false, 322 | }, 323 | 324 | // our state reducers 325 | reducers: { 326 | // select indicates the selected todo id, it will be called when we go 327 | // to a route such as /todos/123 328 | select(state, payload: number) { 329 | return { ...state, selected: payload } 330 | }, 331 | 332 | // request indicates that we are requesting data, so it sets the loading 333 | // flag to true 334 | request(state) { 335 | return { ...state, loading: true }; 336 | }, 337 | 338 | // received is called when we have recieved a single todo item, it adds 339 | // it to the state and clears the loading flag 340 | received(state, payload: Todo) { 341 | return { ...state, 342 | entities: { ...state.entities, 343 | [payload.id]: payload, 344 | }, 345 | loading: false, 346 | }; 347 | }, 348 | 349 | // receivedList updates the state with the full list of todos, as well 350 | // as adding the todos to the entities it also stores their order in 351 | // the ids state, for listing them in the UI 352 | receivedList(state, payload: Todo[]) { 353 | return { ...state, 354 | entities: payload.reduce((map, todo) => { 355 | map[todo.id] = todo 356 | return map 357 | }, {}), 358 | ids: payload.map(todo => todo.id), 359 | loading: false, 360 | }; 361 | }, 362 | }, 363 | 364 | // our async effects 365 | effects(store: Store) { 366 | // reference the dispatch method, which is used in multiple effects 367 | const dispatch = store.getDispatch() 368 | 369 | // return the effect methods so they are wired up by the middleware 370 | return { 371 | // after a todo is selected, we check if it is loaded or not 372 | // if it isn't loaded we dispatch the 'request' action followd by 373 | // the 'received' action. In real life we'd handle failures using a 374 | // 'failed' action to record the error message (for use in the UI). 375 | async select(payload) { 376 | const state = store.getState() 377 | if (!state.todos.entities[state.todos.selected]) { 378 | dispatch.todos.request() 379 | const resp = await fetch(`${endpoint}todos/${payload}`) 380 | const json = await resp.json() 381 | dispatch.todos.received(json) 382 | } 383 | }, 384 | 385 | // load is called to load the full list, whenever we hit the list 386 | // view URL but we avoid re-requesting them if we already have the 387 | // data 388 | async load() { 389 | const state = store.getState() 390 | if (!state.todos.ids.length) { 391 | dispatch.todos.request() 392 | const resp = await fetch(`${endpoint}todos`) 393 | const json = await resp.json() 394 | dispatch.todos.receivedList(json) 395 | } 396 | }, 397 | 398 | // not only can we listen for our own dispatched actions (such as 399 | // the 'select' effect above) but we can also listen for actions from 400 | // other store state branches. In this case, we are interested in the 401 | // route changing and for the views that affects todos, we can then 402 | // dispatch the appropriate actions which will cause data to be loaded 403 | // if required (see effect methods above) 404 | 'routing/change': async function(payload: RoutingState) { 405 | switch (payload.page) { 406 | case 'todos-view': 407 | dispatch.todos.load() 408 | break 409 | case 'todo-view': 410 | dispatch.todos.select(parseInt(payload.params.id)) 411 | break 412 | } 413 | } 414 | } 415 | }) 416 | }) 417 | ``` 418 | 419 | Yes, it's more code than the counter model, but it's a lot less code to write than the Redux equivalent and it contributes less to the JS bundle for your app. 420 | 421 | Note that the effects are dispatchable just like the reducers and they show up in the DevTools just the same. In the example above, calling `dispatch.todos.select(123)` would dispatch an action that would hit the reducer and _then_ the effect of the same name. Whereas calling `dispatch.todos.load()` would still dispatch an action but only run the effect (as there is no matching reducer). 422 | 423 | We can also listen for actions dispatched from other state models, in both the reducers and effects functions. We've seen how this is done to listen for route changes but there are often cases where we may want to act on our local state based on some other dispatched action. As an example, we could clear data from the store when the auth model dispatches a signout action: 424 | 425 | ```ts 426 | export default createModel({ 427 | state, 428 | reducers: { 429 | // ... existing model reducers 430 | 431 | // when user signs out, remove data from state 432 | 'auth/signout': (state) => { 433 | return { ...state, data: [] } 434 | } 435 | } 436 | }) 437 | ``` -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import typescript from '@rollup/plugin-typescript'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import size from 'rollup-plugin-size'; 6 | 7 | export default { 8 | input: { 9 | index: 'src/index.ts', 10 | combineReducers: 'src/combineReducers.ts', 11 | compat: 'src/compat.ts', 12 | const: 'src/const.ts', 13 | createModel: 'src/createModel.ts', 14 | createStore: 'src/createStore.ts', 15 | connect: 'src/connect.ts', 16 | devtools: 'src/devtools.ts', 17 | persist: 'src/persist.ts', 18 | routing: 'src/routingPlugin.ts', 19 | store: 'src/store.ts', 20 | thunk: 'src/thunk.ts', 21 | }, 22 | output: { 23 | dir: 'lib', 24 | format: 'esm', 25 | sourcemap: 'hidden', 26 | }, 27 | plugins: [ 28 | typescript({ typescript: require('typescript') }), 29 | terser(), 30 | size(), 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const browserSync = require('browser-sync').create(); 2 | const compression = require('compression') 3 | 4 | browserSync.init({ 5 | server: true, 6 | startPath: 'test/index.html', 7 | files: [ 8 | 'lib/**', 9 | 'test/**', 10 | ], 11 | middleware: [ 12 | compression({ level: 9, threshold: 1 }), 13 | ], 14 | snippetOptions: { 15 | ignorePaths: ['/', '/**'], 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /src/actionType.ts: -------------------------------------------------------------------------------- 1 | export const actionType = (name: string, key: string) => key.indexOf('/') > -1 ? key : name + '/' + key -------------------------------------------------------------------------------- /src/combineReducers.ts: -------------------------------------------------------------------------------- 1 | import { State, Action, Reducer, Reducers } from "../typings/store" 2 | 3 | export function combineReducers(reducers: R): Reducer> { 4 | return (state: State = {} as State, action: Action) => { 5 | const next = {} as State 6 | for (const key in reducers) { 7 | next[key] = reducers[key](state[key], action) 8 | } 9 | return next 10 | } 11 | } -------------------------------------------------------------------------------- /src/compat.ts: -------------------------------------------------------------------------------- 1 | import { Store as ReduxStore, Reducer as ReduxReducer, AnyAction, Middleware, Observable } from 'redux' 2 | import { Store, ActionEvent, Reducer } from '../typings/store' 3 | import { dispatchEvent, stateEvent } from './const' 4 | 5 | // compatibility wrapper to make store provide the Redux API 6 | export function compat(store: Store): ReduxStore { 7 | return { 8 | dispatch(action: AnyAction) { 9 | return store.dispatch(action) 10 | }, 11 | 12 | subscribe(listener) { 13 | store.addEventListener(stateEvent, listener) 14 | return () => store.removeEventListener(stateEvent, listener) 15 | }, 16 | 17 | getState() { 18 | return store.state 19 | }, 20 | 21 | replaceReducer(reducer: ReduxReducer) { 22 | store.reducer = reducer as Reducer 23 | }, 24 | 25 | // TODO: implement observable ... 26 | [Symbol.observable]() { return {} as Observable } 27 | } 28 | } 29 | 30 | // adaptor to use existing redux middleware(s) 31 | export function applyMiddleware(store: Store, ...middlewares: Middleware[]) { 32 | const compatStore = compat(store) 33 | 34 | middlewares.forEach(middleware => { 35 | const api = middleware(compatStore) 36 | store.addEventListener(dispatchEvent, e => { 37 | const evt = >e 38 | const { action } = evt.detail 39 | const next = api(action => action) 40 | const result = next(action) 41 | if (result) { 42 | evt.detail.action = result 43 | } else { 44 | e.stopImmediatePropagation() 45 | e.preventDefault() 46 | } 47 | }) 48 | }) 49 | 50 | return store 51 | } -------------------------------------------------------------------------------- /src/connect.ts: -------------------------------------------------------------------------------- 1 | import { Store } from '../typings/store' 2 | import { Constructor, Connectable, DispatchMap } from '../typings/connect' 3 | import { stateEvent } from './const' 4 | 5 | const dispatchMap: unique symbol = Symbol() 6 | const createDispatchMap: unique symbol = Symbol() 7 | const addEventListeners: unique symbol = Symbol() 8 | const removeEventListeners: unique symbol = Symbol() 9 | const addStateSubscription: unique symbol = Symbol() 10 | const removeStateSubscription: unique symbol = Symbol() 11 | const onStateChange: unique symbol = Symbol() 12 | 13 | export function connect, S>( 14 | store: Store, 15 | superclass: T 16 | ) { 17 | class connected extends superclass { 18 | private [dispatchMap]: DispatchMap 19 | 20 | constructor(...args: any[]) { 21 | super(...args) 22 | this[onStateChange] = this[onStateChange].bind(this) 23 | this[createDispatchMap]() 24 | } 25 | 26 | connectedCallback() { 27 | if (super.connectedCallback) { 28 | super.connectedCallback() 29 | } 30 | 31 | this[addEventListeners]() 32 | this[addStateSubscription]() 33 | } 34 | 35 | disconnectedCallback() { 36 | this[removeStateSubscription]() 37 | this[removeEventListeners]() 38 | 39 | if (super.disconnectedCallback) { 40 | super.disconnectedCallback() 41 | } 42 | } 43 | 44 | private [createDispatchMap]() { 45 | this[dispatchMap] = this.mapEvents 46 | ? this.mapEvents() 47 | : {} 48 | } 49 | 50 | private [addEventListeners]() { 51 | for (const key in this[dispatchMap]) { 52 | this.addEventListener(key, this[dispatchMap][key] as EventListener, false) 53 | } 54 | } 55 | 56 | private [removeEventListeners]() { 57 | for (const key in this[dispatchMap]) { 58 | this.removeEventListener(key, this[dispatchMap][key] as EventListener, false) 59 | } 60 | } 61 | 62 | private [addStateSubscription]() { 63 | store.addEventListener(stateEvent, this[onStateChange]) 64 | this[onStateChange]() 65 | } 66 | 67 | private [removeStateSubscription]() { 68 | store.removeEventListener(stateEvent, this[onStateChange]) 69 | } 70 | 71 | private [onStateChange]() { 72 | this.mapState && Object.assign(this, this.mapState(store.state)) 73 | } 74 | } 75 | 76 | return connected as Constructor & T 77 | } -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const dispatchEvent = 'action' 2 | export const stateEvent = 'state' -------------------------------------------------------------------------------- /src/createModel.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../typings/model' 2 | 3 | export const createModel = (model: Model) => model -------------------------------------------------------------------------------- /src/createStore.ts: -------------------------------------------------------------------------------- 1 | import { Plugins, Config, ModelStore, ConfigModels } from '../typings/modelStore' 2 | import { Reducer, Action } from '../typings/store' 3 | 4 | import { actionType } from './actionType' 5 | import { dispatchPlugin } from './dispatchPlugin' 6 | import { effectsPlugin } from './effectsPlugin' 7 | import { combineReducers } from './combineReducers' 8 | import { Store } from './store' 9 | 10 | const corePlugins: Plugins = { dispatchPlugin, effectsPlugin } 11 | 12 | export const createStore = (config: C): ModelStore> => { 13 | const models = { ...config.models } 14 | 15 | // add models from plugins 16 | const plugins: Plugins = { ...corePlugins, ...config.plugins } 17 | for (const name in plugins) { 18 | const plugin = plugins[name] 19 | if (plugin.model) { 20 | models[name] = plugin.model 21 | } 22 | } 23 | 24 | // create reducers 25 | const reducers: { [name: string]: Reducer } = {} 26 | for (const name in models) { 27 | const model = models[name] 28 | const modelReducers: { [name: string]: any } = {} 29 | 30 | for (const k in model.reducers) { 31 | modelReducers[actionType(name, k)] = model.reducers[k] 32 | } 33 | 34 | reducers[name] = (state: any = model.state, action: Action) => { 35 | const reducer = modelReducers[action.type!] 36 | return reducer ? reducer(state, action.payload) : state 37 | } 38 | } 39 | 40 | // create store 41 | const rootReducer = combineReducers(reducers) 42 | const initialState = config && config.state 43 | const store = >>new Store(initialState, rootReducer) 44 | 45 | // give each plugin chance to handle the models 46 | for (const name in plugins) { 47 | const plugin = plugins[name] 48 | if (plugin.onModel) { 49 | for (const name in models) { 50 | plugin.onModel(store, name, models[name]) 51 | } 52 | } 53 | } 54 | 55 | // initialize plugins 56 | for (const name in plugins) { 57 | const plugin = plugins[name] 58 | if (plugin.onStore) { 59 | plugin.onStore(store) 60 | } 61 | } 62 | 63 | return >>store 64 | } 65 | -------------------------------------------------------------------------------- /src/devtools.ts: -------------------------------------------------------------------------------- 1 | import { Store, ActionEvent } from '../typings/store' 2 | import { stateEvent } from './const' 3 | 4 | declare global { 5 | interface Window { 6 | __REDUX_DEVTOOLS_EXTENSION__: any 7 | } 8 | } 9 | 10 | const isJumpToState = (action: any) => action.type === 'DISPATCH' 11 | 12 | export function devtools(store: T, options?: any) { 13 | const extension = window.__REDUX_DEVTOOLS_EXTENSION__ 14 | 15 | if (extension) { 16 | const devtools = extension.connect(options) 17 | 18 | store.addEventListener(stateEvent, e => { 19 | const { action } = (>e).detail 20 | if (!isJumpToState(action)) { 21 | devtools.send(action, store.state) 22 | } 23 | }) 24 | 25 | devtools.subscribe((action: any) => { 26 | if (isJumpToState(action)) { 27 | store.state = JSON.parse(action.state) 28 | store.dispatchEvent(new CustomEvent(stateEvent, { detail: { action } })) 29 | } 30 | }) 31 | 32 | devtools.init(store.state) 33 | } 34 | 35 | return store 36 | } -------------------------------------------------------------------------------- /src/dispatchPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "../typings/model" 2 | import { ModelStore } from "../typings/modelStore" 3 | 4 | import { actionType } from "./actionType" 5 | 6 | export const createDispatcher = (store: ModelStore, name: string, key: string) => { 7 | const type = actionType(name, key) 8 | store.dispatch[name][key] = (payload?: any): any => { 9 | const action = { type, ...(payload !== undefined && { payload }) } 10 | return store.dispatch(action) 11 | } 12 | return type 13 | } 14 | 15 | export const dispatchPlugin = { 16 | onModel(store: ModelStore, name: string, model: Model) { 17 | store.dispatch[name] = {} 18 | 19 | for (const key in model.reducers) { 20 | createDispatcher(store, name, key) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/effectsPlugin.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ModelStore } from "../typings/modelStore" 3 | import { Model, EffectFn } from "../typings/model" 4 | import { ActionEvent } from "../typings/store" 5 | 6 | import { createDispatcher } from "./dispatchPlugin" 7 | import { stateEvent } from "./const" 8 | 9 | const effects: { [type: string]: EffectFn[] } = {} 10 | const inits: Function[] = [] 11 | 12 | export const effectsPlugin = { 13 | onModel(store: ModelStore, name: string, model: Model) { 14 | if (!model.effects) { 15 | return 16 | } 17 | 18 | const modelEffects = model.effects({ 19 | getDispatch: () => store.dispatch, 20 | getState: () => store.state 21 | }) 22 | 23 | for (const key in modelEffects) { 24 | const type = createDispatcher(store, name, key) 25 | const effect = modelEffects[key] 26 | 27 | // effects are a list, because multiple models may want to listen to the same 28 | // action type (e.g. routing/change) and we want to trigger _all_ of them ... 29 | if (effects[type]) { 30 | effects[type].push(effect) 31 | } else { 32 | effects[type] = [effect] 33 | } 34 | 35 | if (key === 'init') { 36 | inits.push(effect) 37 | } 38 | } 39 | }, 40 | 41 | onStore(store: ModelStore) { 42 | store.addEventListener(stateEvent, e => { 43 | const { action } = (>e).detail 44 | const runEffects = effects[action.type!] 45 | 46 | if (runEffects) { 47 | // allow the triggering action to be reduced first 48 | // before we handle the effect(s) running 49 | queueMicrotask(() => runEffects.forEach(effect => effect(action.payload))) 50 | } 51 | }) 52 | 53 | // allow other store decorators to 'wire up' before initialization 54 | queueMicrotask(() => inits.forEach(effect => effect())) 55 | }, 56 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './combineReducers' 2 | export * from './connect' 3 | export * from './const' 4 | export * from './createModel' 5 | export * from './createStore' 6 | export * from './devtools' 7 | export * from './persist' 8 | export * from './routingPlugin' 9 | export * from './store' 10 | export * from './thunk' -------------------------------------------------------------------------------- /src/persist.ts: -------------------------------------------------------------------------------- 1 | import { Store, Action, ActionEvent } from '../typings/store' 2 | import { PersistOptions } from '../typings/persist' 3 | import { stateEvent } from './const' 4 | 5 | export function persist(store: T, options?: Partial>) { 6 | const opt = { 7 | name: location.hostname, 8 | storage: localStorage, 9 | serializer: JSON, 10 | filter: (_: Action) => true, 11 | persist: (state: S) => state, 12 | delay: 0, 13 | ...options 14 | } 15 | 16 | const state = opt.storage.getItem(opt.name) 17 | if (state) { 18 | store.state = { ...store.state, ...opt.serializer.parse(state) } 19 | } 20 | 21 | let task = 0 22 | 23 | store.addEventListener(stateEvent, e => { 24 | const { action } = (>e).detail 25 | 26 | if (opt.filter(action)) { 27 | if (task) { 28 | window.clearTimeout(task) 29 | } 30 | task = window.setTimeout(() => { 31 | opt.storage.setItem(opt.name, opt.serializer.stringify(opt.persist(store.state))) 32 | task = 0 33 | }, opt.delay) 34 | } 35 | }) 36 | 37 | return store 38 | } 39 | -------------------------------------------------------------------------------- /src/routingPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Matcher, Result } from '@captaincodeman/router' 2 | 3 | import { RoutingOptions, RoutingState, RoutingDispatch } from '../typings/routing' 4 | import { ModelStore } from '../typings/modelStore' 5 | 6 | import { createModel } from './createModel' 7 | 8 | const history = window.history 9 | const popstate = 'popstate' 10 | const dispatchPopstate = () => dispatchEvent(new Event(popstate)) 11 | 12 | export const routingPlugin = (router: Matcher, options?: Partial>) => { 13 | const opt = >{ 14 | transform: (result) => result, 15 | ...options, 16 | } 17 | 18 | return { 19 | model: createModel({ 20 | state: >{ page: undefined, params: {} }, 21 | 22 | reducers: { 23 | change: (_state: any, payload: RoutingState): RoutingState => payload 24 | }, 25 | 26 | effects: (_store: any) => ({ 27 | back() { 28 | history.back() 29 | dispatchPopstate() 30 | }, 31 | 32 | forward() { 33 | history.forward() 34 | dispatchPopstate() 35 | }, 36 | 37 | go(payload: number) { 38 | history.go(payload) 39 | dispatchPopstate() 40 | }, 41 | 42 | push(href: string) { 43 | history.pushState(null, '', href) 44 | dispatchPopstate() 45 | }, 46 | 47 | replace(href: string) { 48 | history.replaceState(null, '', href) 49 | dispatchPopstate() 50 | }, 51 | }), 52 | }), 53 | 54 | onStore(store: ModelStore) { 55 | // TODO: pass in typed 'this' so it can access it's own dispatch + state 56 | // also, name should be whatever name is being assigned to this plugin 57 | const dispatch = store.dispatch['routing'] as unknown as RoutingDispatch 58 | 59 | // listen for route changes 60 | const routeChanged = () => { 61 | const route = router(location.pathname) 62 | if (route) { 63 | dispatch.change(opt.transform(route)) 64 | } 65 | } 66 | 67 | window.addEventListener(popstate, routeChanged) 68 | 69 | // listen for click events 70 | window.addEventListener('click', (e: MouseEvent) => { 71 | const href = clickHandler(e) 72 | 73 | // handler returns null if we're not to handle it 74 | if (href) { 75 | e.preventDefault() 76 | history.pushState(null, '', href) 77 | dispatchPopstate() 78 | } 79 | }) 80 | 81 | // although we _could_ populate the initial route at create time 82 | // it makes things easier if the app can listen for "route changes" 83 | // in a consistent way without special-casing it. We do this using 84 | // a microtask so that if the devtools middleware is being added, 85 | // this initial dispatch can be captured by it 86 | queueMicrotask(routeChanged) 87 | } 88 | } 89 | } 90 | 91 | // link is 'internal' to the app if it's within the baseURI of the page (handles sub-apps) 92 | const isInternal = (a: HTMLAnchorElement) => a.href.startsWith(document.baseURI) 93 | 94 | // external isn't just !internal, it's having an attribute explicitly marking it as such 95 | const isExternal = (a: HTMLAnchorElement) => (a.getAttribute('rel') || '').includes('external') 96 | 97 | // download links may be within the app so need to be specifically ignored if utilized 98 | const isDownload = (a: HTMLAnchorElement) => a.hasAttribute('download') 99 | 100 | // if the link is meant to open in a new tab or window, we need to allow it to function 101 | const isTargeted = (a: HTMLAnchorElement) => a.target 102 | 103 | // if a non-default click or modifier key is used with the click, we leave native behavior 104 | const isModified = (e: MouseEvent) => (e.button && e.button !== 0) 105 | || e.metaKey 106 | || e.altKey 107 | || e.ctrlKey 108 | || e.shiftKey 109 | || e.defaultPrevented 110 | 111 | // get the anchor element clicked on, taking into account shadowDom components 112 | const getAnchor = (e: MouseEvent) => e.composedPath().find(n => (n as HTMLElement).tagName === 'A') 113 | 114 | // standard handler contains complete logic for what to ignore 115 | const clickHandler = (e: MouseEvent) => { 116 | const anchor = getAnchor(e) 117 | return anchor === undefined // not a link 118 | || !isInternal(anchor) 119 | || isDownload(anchor) 120 | || isExternal(anchor) 121 | || isTargeted(anchor) 122 | || isModified(e) 123 | ? null 124 | : anchor.href 125 | } 126 | 127 | // parseQuery creates an additional object based on querystring parameters 128 | // not every app will require this so we can make it optional by setting 129 | // the transform to withQuerystring 130 | 131 | export const withQuerystring = (result: Result) => { 132 | const params = new URLSearchParams(location.search) 133 | const queries = parseQuery(params) 134 | return >{ ...result, queries } 135 | } 136 | 137 | function parseQuery(params: URLSearchParams) { 138 | const q: { [key: string]: string | string[] } = {} 139 | for (const p of params.entries()) { 140 | const [k, v] = p 141 | const c = q[k] 142 | if (c) { 143 | if (Array.isArray(c)) { 144 | c.push(v) 145 | } else { 146 | q[k] = [c, v] 147 | } 148 | } else { 149 | q[k] = v 150 | } 151 | } 152 | return q 153 | } 154 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { Action, ActionEvent, Reducer } from '../typings/store' 2 | import { dispatchEvent, stateEvent } from './const' 3 | 4 | export class Store extends EventTarget { 5 | constructor(public state: S, public reducer: Reducer) { 6 | super() 7 | 8 | // dispatch an empty action so all reducers can initialize 9 | this.state = this.reducer(this.state, {}) 10 | } 11 | 12 | dispatch(action: Action) { 13 | // event contains the action to dispatch 14 | const evt = new CustomEvent(dispatchEvent, { 15 | cancelable: true, 16 | detail: { action }, 17 | }) 18 | 19 | // only process through reducers if it _wasn't_ cancelled 20 | if (this.dispatchEvent(evt)) { 21 | // middleware _might_ have modified the action ... 22 | action = evt.detail.action 23 | 24 | this.state = this.reducer(this.state, action) 25 | 26 | // notify subscribers that the state has changed 27 | this.dispatchEvent(new CustomEvent(stateEvent, { 28 | detail: { action }, 29 | })) 30 | } 31 | 32 | return action 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/thunk.ts: -------------------------------------------------------------------------------- 1 | import { Store, ActionEvent } from '../typings/store' 2 | import { ThunkAction } from '../typings/thunk' 3 | import { dispatchEvent } from './const' 4 | 5 | export function thunk(store: T) { 6 | const dispatch = store.dispatch.bind(store) 7 | 8 | store.addEventListener(dispatchEvent, e => { 9 | const { action } = (>e).detail 10 | 11 | if (typeof action === 'function') { 12 | const thunk = action 13 | thunk(dispatch, () => store.state) 14 | 15 | // stop event going to other listeners (we've handled it) 16 | e.stopImmediatePropagation() 17 | 18 | // stop action being dispatched to reducer (it's a function) 19 | e.preventDefault() 20 | } 21 | }) 22 | 23 | return store 24 | } 25 | -------------------------------------------------------------------------------- /test/compat.js: -------------------------------------------------------------------------------- 1 | import { Store } from '../lib/index.js' 2 | import { applyMiddleware } from '../lib/compat.js' 3 | const { expect } = chai 4 | 5 | describe('compat', function() { 6 | let store, count = 0 7 | 8 | // test middleware that can handle an action or not 9 | const testMiddleware = store => next => action => action.type === 'handled' 10 | ? undefined 11 | : next(action) 12 | 13 | const reducer = (state = 0, action) => { 14 | count++ 15 | switch (action.type) { 16 | case 'inc': 17 | return state + action.payload 18 | default: 19 | return state 20 | } 21 | } 22 | 23 | this.beforeEach(function() { 24 | this.timeout(60*60*60); 25 | count = -1 // accounts for the first reducer call 26 | store = new Store(undefined, reducer) 27 | }) 28 | 29 | it('should handle in middleware', async function() { 30 | const s = applyMiddleware(store, testMiddleware) 31 | s.dispatch({ type: 'handled'}) 32 | expect(count).to.equal(0) 33 | }) 34 | 35 | it('should not handle in middleware', async function() { 36 | const s = applyMiddleware(store, testMiddleware) 37 | s.dispatch({ type: 'unhandled'}) 38 | expect(count).to.equal(1) 39 | }) 40 | }) -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tests 5 | 6 | 7 | 8 |
9 | 10 | 11 | 14 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/store.js: -------------------------------------------------------------------------------- 1 | import { Store, combineReducers, thunk } from '../lib/index.js' 2 | const { expect } = chai 3 | 4 | describe('store', function() { 5 | const reducers = { 6 | count: (state = 0, action) => { 7 | switch (action.type) { 8 | case 'inc': 9 | return state + action.payload 10 | default: 11 | return state 12 | } 13 | }, 14 | name: (state = '', action) => { 15 | switch (action.type) { 16 | case 'rename': 17 | return action.payload 18 | default: 19 | return state 20 | } 21 | } 22 | } 23 | 24 | const reducer = combineReducers(reducers) 25 | let store 26 | 27 | this.beforeEach(function() { 28 | store = new Store(undefined, reducer) 29 | }) 30 | 31 | it('should create', function() { 32 | expect(store).not.null 33 | expect(store.state).not.null 34 | }) 35 | 36 | it('should set initial state from reducers', function() { 37 | expect(store.state).not.null 38 | expect(store.state.count).equal(0) 39 | expect(store.state.name).equal('') 40 | }) 41 | 42 | it('should call reducers when action dispatched', function() { 43 | store.dispatch({ type: 'inc', payload: 5 }) 44 | store.dispatch({ type: 'inc', payload: 2 }) 45 | store.dispatch({ type: 'rename', payload: 'CaptainCodeman' }) 46 | expect(store.state.count).equal(7) 47 | expect(store.state.name).equal('CaptainCodeman') 48 | }) 49 | 50 | it('should dispatch event when action dispatched', function() { 51 | let action 52 | store.addEventListener('action', e => action = e.detail.action) 53 | store.dispatch({ type: 'rename', payload: 'CaptainCodeman' }) 54 | expect(action).deep.equal({ type: 'rename', payload: 'CaptainCodeman' }) 55 | }) 56 | 57 | it('should dispatch event when state updated', function() { 58 | let action 59 | store.addEventListener('state', e => action = e.detail.action) 60 | store.dispatch({ type: 'rename', payload: 'CaptainCodeman' }) 61 | expect(action).deep.equal({ type: 'rename', payload: 'CaptainCodeman' }) 62 | }) 63 | 64 | it('should call thunks', async function() { 65 | let called = false 66 | const s = thunk(store) 67 | s.dispatch(() => called = true) 68 | expect(called).true 69 | }) 70 | 71 | it('should dispatch from async thunks', async function() { 72 | let action, r 73 | const p = new Promise(resolve => r = resolve) 74 | const s = thunk(store) 75 | s.addEventListener('action', e => action = e.detail.action) 76 | s.dispatch(async () => { 77 | await new Promise(r => setTimeout(r, 10)) 78 | s.dispatch({ type: 'rename', payload: 'CaptainCodeman' }) 79 | r() 80 | }) 81 | await p 82 | expect(action).deep.equal({ type: 'rename', payload: 'CaptainCodeman' }) 83 | }) 84 | 85 | // test actions handled by middleware (not hitting reducer) 86 | // test middleware chaining 87 | // test devtools 88 | // test persistence 89 | // test element connect 90 | }) -------------------------------------------------------------------------------- /test/types/combineReducers.ts: -------------------------------------------------------------------------------- 1 | import { assert, IsExact } from "conditional-type-checks" 2 | import { combineReducers } from 'combineReducers' 3 | import { Action } from "../../typings/store" 4 | 5 | interface State { 6 | count: number 7 | name: string 8 | } 9 | 10 | const reducers = { 11 | count: (state: number, action: Action) => state + action.payload!, 12 | name: (state: string, action: Action) => state + action.payload!, 13 | } 14 | 15 | const reducer = combineReducers(reducers) 16 | 17 | assert>(true) 18 | 19 | const state = reducer({ count: 0, name: '' }, {}) 20 | 21 | assert>(true) -------------------------------------------------------------------------------- /test/types/connect.ts: -------------------------------------------------------------------------------- 1 | import { assert, IsExact } from "conditional-type-checks" 2 | 3 | import { DispatchMap } from '../../typings/connect' 4 | 5 | const map: DispatchMap = { 6 | 'click': (_e: Event) => {}, 7 | 'my-event': (_e: CustomEvent) => {}, 8 | } 9 | 10 | assert>(true) -------------------------------------------------------------------------------- /test/types/effects-config.ts: -------------------------------------------------------------------------------- 1 | import { createModel } from "../../typings/model" 2 | import { Store } from "./effects-types" 3 | 4 | export const testModel = createModel({ 5 | state: 0, 6 | reducers: { 7 | add(state: number, payload: number) { 8 | return state + payload 9 | }, 10 | }, 11 | effects: (store: Store) => ({ 12 | async run(_payload: string) { 13 | // dispatch returns the typed, store dispatch 14 | store.getDispatch().test.add(10) 15 | 16 | // getState returns the typed, store state 17 | const state = store.getState() 18 | 19 | // woot, typed! 20 | console.log(state.test) 21 | } 22 | }), 23 | }) 24 | 25 | export const testConfig = { 26 | models: { 27 | test: testModel, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /test/types/effects-types.ts: -------------------------------------------------------------------------------- 1 | import { StoreState, StoreDispatch } from '../../typings/modelStore' 2 | import { ModelStore } from '../../typings/model' 3 | 4 | import { testConfig } from './effects-config' 5 | 6 | export interface State extends StoreState { } 7 | export interface Dispatch extends StoreDispatch { } 8 | export interface Store extends ModelStore { } 9 | -------------------------------------------------------------------------------- /test/types/effects.ts: -------------------------------------------------------------------------------- 1 | import { assert, IsExact } from "conditional-type-checks" 2 | 3 | import { ExtractConfigModels, ConfigModels } from '../../typings/modelStore' 4 | 5 | import { testConfig, testModel } from './effects-config' 6 | 7 | type testExtractConfigModels = ExtractConfigModels 8 | assert>(true) 11 | 12 | type testConfigModels = ConfigModels 13 | assert>(true) 16 | -------------------------------------------------------------------------------- /test/types/index.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 3.5 2 | // see https://github.com/Microsoft/dtslint#specify-a-typescript-version for more information 3 | -------------------------------------------------------------------------------- /test/types/model.ts: -------------------------------------------------------------------------------- 1 | import { assert, IsExact } from "conditional-type-checks" 2 | 3 | import { ActionFromModelReducerFn, ActionsFromModelReducerFns, ActionFromModelEffectFn, ActionsFromModelEffectFns, ModelDispatch } from "../../typings/model" 4 | 5 | enum Things { 6 | One, 7 | Two, 8 | } 9 | 10 | // just some state with different property types 11 | interface TestState { 12 | value: number 13 | } 14 | 15 | // different patterns of reducer method arguments 16 | // doesn't matter that reducer doesn't user payload - we're just testing the type transforms 17 | const testReducers = { 18 | object(state: TestState, _payload: { count: number }) { 19 | return state 20 | }, 21 | number(state: TestState, _payload: number) { 22 | return state 23 | }, 24 | void(state: TestState) { 25 | return state 26 | }, 27 | boolean(state: TestState, _payload: boolean) { 28 | return state 29 | }, 30 | string(state: TestState, _payload: string) { 31 | return state 32 | }, 33 | enum(state: TestState, _payload: Things) { 34 | return state 35 | } 36 | } 37 | 38 | // expected dispatcher for the reducers above 39 | interface testDispatcherType { 40 | object: (payload: { count: number }) => void 41 | number: (payload: number) => void 42 | void: () => void 43 | boolean: (payload: boolean) => void 44 | string: (payload: string) => void 45 | enum: (payload: Things) => void 46 | } 47 | 48 | // test individual patterns 49 | type objectActionType = ActionFromModelReducerFn 50 | assert void, objectActionType>>(true) 51 | 52 | type numberActionType = ActionFromModelReducerFn 53 | assert void, numberActionType>>(true) 54 | 55 | type voidActionType = ActionFromModelReducerFn 56 | assert void, voidActionType>>(true) 57 | 58 | type booleanActionType = ActionFromModelReducerFn 59 | assert void, booleanActionType>>(true) 60 | 61 | type stringActionType = ActionFromModelReducerFn 62 | assert void, stringActionType>>(true) 63 | 64 | type enumActionType = ActionFromModelReducerFn 65 | assert void, enumActionType>>(true) 66 | 67 | // test entire dispatch interface 68 | type actionsDispatcherType = ActionsFromModelReducerFns 69 | assert>(true) 70 | 71 | // different patterns of effect method arguments 72 | const testEffects = { 73 | object(_payload: { count: number }) { }, 74 | number(_payload: number) { }, 75 | void() { }, 76 | boolean(_payload: boolean) { }, 77 | string(_payload: string) { }, 78 | enum(_payload: Things) { }, 79 | } 80 | 81 | type objectEffectType = ActionFromModelEffectFn 82 | assert void, objectEffectType>>(true) 83 | 84 | type numberEffectType = ActionFromModelEffectFn 85 | assert void, numberEffectType>>(true) 86 | 87 | type voidEffectType = ActionFromModelEffectFn 88 | assert void, voidEffectType>>(true) 89 | 90 | type booleanEffectType = ActionFromModelEffectFn 91 | assert void, booleanEffectType>>(true) 92 | 93 | type stringEffectType = ActionFromModelEffectFn 94 | assert void, stringEffectType>>(true) 95 | 96 | type enumEffectType = ActionFromModelEffectFn 97 | assert void, enumEffectType>>(true) 98 | 99 | // test entire dispatch interface 100 | type effectsDispatcherType = ActionsFromModelEffectFns 101 | assert>(true) 102 | 103 | // test combined (same reducers and effects) 104 | type dispatcherType = ModelDispatch 105 | assert>(true) 106 | 107 | const testCombineReducers = { 108 | add(state: number, payload: number) { 109 | return state + payload 110 | } 111 | } 112 | 113 | const testCombineEffects = { 114 | async load(_payload: string) { } 115 | } 116 | 117 | // expected dispatcher for the reducers above 118 | interface testCombinedDispatcherType { 119 | add: (payload: number) => void 120 | load: (payload: string) => void 121 | } 122 | 123 | // test combined (different reducers and effects) 124 | type combinedDispatcherType = ModelDispatch 125 | assert>(true) 126 | -------------------------------------------------------------------------------- /test/types/plugins.ts: -------------------------------------------------------------------------------- 1 | import { assert, IsExact } from "conditional-type-checks" 2 | 3 | import { ExtractModelsFromPlugins, KeysOfPluginsWithModels, PluginsModels, ExtractConfigPlugins, ExtractConfigModels, ConfigModels } from '../../typings/modelStore' 4 | import { createModel } from "createModel" 5 | 6 | const oneModel = createModel({ 7 | state: 0, 8 | reducers: { 9 | add(state: number, payload: number) { 10 | return state + payload 11 | }, 12 | } 13 | }) 14 | 15 | const twoModel = createModel({ 16 | state: 'abc', 17 | reducers: { 18 | append(state: string, payload: string) { 19 | return state + payload 20 | }, 21 | } 22 | }) 23 | 24 | const testConfig = { 25 | models: { 26 | one: oneModel, 27 | }, 28 | plugins: { 29 | two: { model: twoModel }, 30 | three: {}, 31 | }, 32 | } 33 | 34 | type testExtractModelsFromPlugins = ExtractModelsFromPlugins 35 | assert>(true) 39 | 40 | type testKeysOfPluginsWithModels = KeysOfPluginsWithModels 41 | assert>(true) 42 | 43 | type testPluginsModels = PluginsModels 44 | assert>(true) 47 | 48 | type testExtractedPluginsModels = PluginsModels> 49 | assert>(true) 52 | 53 | type testExtractConfigModels = ExtractConfigModels 54 | assert>(true) 57 | 58 | type testConfigModels = ConfigModels 59 | assert>(true) 63 | -------------------------------------------------------------------------------- /test/types/store.ts: -------------------------------------------------------------------------------- 1 | import { assert, Has, IsExact } from "conditional-type-checks" 2 | 3 | import createMatcher from '@captaincodeman/router' 4 | 5 | import { createModel } from '../../typings/model' 6 | import { StoreDispatch, StoreState } from '../../typings/modelStore' 7 | import { RoutingState, routingPlugin } from '../../typings/routing' 8 | 9 | const routes = { 10 | '/test': 'test-view', 11 | } 12 | 13 | const matcher = createMatcher(routes) 14 | const routing = routingPlugin(matcher) 15 | 16 | const configWithRouting = { 17 | models: { 18 | count: createModel({ 19 | state: 0, 20 | reducers: { 21 | inc(state) { 22 | return state + 1 23 | }, 24 | incBy(state, val: number) { 25 | return state + val 26 | }, 27 | } 28 | }), 29 | }, 30 | plugins: { 31 | routing, 32 | } 33 | } 34 | 35 | type dispatchWithRouting = StoreDispatch 36 | 37 | assert void, 40 | incBy: (payload: number) => void 41 | }, 42 | routing: { 43 | back: () => void, 44 | change: (payload: RoutingState) => void, 45 | forward: () => void, 46 | go: (payload: number) => void, 47 | push: (payload: string) => void, 48 | replace: (payload: string) => void, 49 | }, 50 | }>>(true) 51 | 52 | type stateWithRouting = StoreState 53 | 54 | assert, 57 | }, stateWithRouting>>(true) 58 | 59 | const configNoPlugins = { 60 | models: { 61 | count: createModel({ 62 | state: 0, 63 | reducers: { 64 | inc(state) { 65 | return state + 1 66 | }, 67 | incBy(state, val: number) { 68 | return state + val 69 | }, 70 | } 71 | }), 72 | }, 73 | } 74 | 75 | type dispatchNoPlugins = StoreDispatch 76 | 77 | assert void, 80 | incBy: (payload: number) => void 81 | }, 82 | }>>(true) 83 | 84 | type stateNoPlugins = StoreState 85 | 86 | assert>(true) -------------------------------------------------------------------------------- /test/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | // this additional tsconfig is required by dtslint 2 | // see: https://github.com/Microsoft/dtslint#typestsconfigjson 3 | { 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "lib": [ 7 | "esnext", 8 | "dom" 9 | ], 10 | "noImplicitAny": true, 11 | "noImplicitThis": true, 12 | "noUnusedLocals": false, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "noEmit": true, 16 | "baseUrl": "../../src", 17 | "paths": { 18 | "@captaincodeman/rdx": ["index"] 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /test/types/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["dtslint/dtslint.json"], 3 | "rules": { 4 | "eofline": false, 5 | "no-consecutive-blank-lines": false, 6 | "no-relative-import-in-test": false, 7 | "no-trailing-whitespace": false, 8 | "no-useless-files": false, 9 | "semicolon": [true, "never"], 10 | "object-literal-key-quotes": false 11 | } 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "sourceMap": true, 5 | "module": "esnext", 6 | "target": "esnext", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "noUnusedParameters": true, 10 | "noUnusedLocals": true, 11 | "noImplicitReturns": true, 12 | "noImplicitAny": true, 13 | "lib": [ 14 | "esnext", 15 | "dom", 16 | "dom.iterable" 17 | ], 18 | "baseUrl": "src", 19 | "paths": { 20 | "@captaincodeman/rdx": ["index"] 21 | } 22 | }, 23 | } -------------------------------------------------------------------------------- /typings/combineReducers.d.ts: -------------------------------------------------------------------------------- 1 | import { Reducer, Reducers, State } from './store' 2 | 3 | export declare function combineReducers(reducers: R): Reducer> -------------------------------------------------------------------------------- /typings/compat.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "./store" 2 | import { Middleware } from "redux" 3 | 4 | export function applyMiddleware(store: Store, 5 | middleware1: Middleware[], 6 | ): Store 7 | export function applyMiddleware(store: Store, 8 | middleware1: Middleware[], 9 | middleware2: Middleware[], 10 | ): Store 11 | export function applyMiddleware(store: Store, 12 | middleware1: Middleware[], 13 | middleware2: Middleware[], 14 | middleware3: Middleware[], 15 | ): Store 16 | export function applyMiddleware(store: Store, 17 | middleware1: Middleware[], 18 | middleware2: Middleware[], 19 | middleware3: Middleware[], 20 | middleware4: Middleware[], 21 | ): Store 22 | export function applyMiddleware(store: Store, 23 | middleware1: Middleware[], 24 | middleware2: Middleware[], 25 | middleware3: Middleware[], 26 | middleware4: Middleware[], 27 | middleware5: Middleware[], 28 | ): Store 29 | export function applyMiddleware(store: Store, ...middlewares: Middleware[]): Store -------------------------------------------------------------------------------- /typings/connect.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from './store' 2 | 3 | export interface DispatchMap { [key: string]: (event: T) => void } 4 | 5 | interface ConnectProps { 6 | mapState?(state: any): { [key: string]: any } 7 | } 8 | 9 | interface ConnectEvents { 10 | mapEvents?(): DispatchMap 11 | } 12 | 13 | export interface Connectable extends HTMLElement, ConnectProps, ConnectEvents { 14 | connectedCallback?(): void 15 | disconnectedCallback?(): void 16 | } 17 | 18 | export type Constructor = new (...args: any[]) => T 19 | 20 | export declare function connect, S>( 21 | store: Store, 22 | superclass: T 23 | ): Constructor & T -------------------------------------------------------------------------------- /typings/devtools.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from './store' 2 | 3 | export declare function devtools(store: T, options?: any): T -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './combineReducers' 2 | export * from './connect' 3 | export * from './devtools' 4 | export * from './model' 5 | export * from './models' 6 | export { Plugin, Plugins, Config, createStore, StoreState, StoreDispatch} from './modelStore' 7 | export * from './persist' 8 | export * from './routing' 9 | export * from './store' 10 | export * from './thunk' 11 | -------------------------------------------------------------------------------- /typings/model.d.ts: -------------------------------------------------------------------------------- 1 | import { GetState } from "./store" 2 | 3 | export type ReducerFn = (state: S, payload: P) => S 4 | 5 | interface ReducerFns { 6 | [key: string]: ReducerFn 7 | } 8 | 9 | export type EffectFn

= (payload?: P) => void 10 | 11 | interface EffectFns { 12 | [key: string]: EffectFn 13 | } 14 | 15 | export interface ModelStore { 16 | // getDispatch has to be a function that returns the Dispatch for the store 17 | // otherwise it creates a circular reference when added to the models' effects 18 | getDispatch: () => D 19 | getState: GetState 20 | } 21 | 22 | // TODO: constraint to limit reducers + effects with the same name, to the same payload 23 | export interface Model = any, E extends EffectFns = any> { 24 | state: S 25 | reducers: R 26 | effects?: (store: ModelStore) => E 27 | [key: string]: any 28 | } 29 | 30 | type ActionFromModelReducerFn> = 31 | R extends (state: S) => S ? () => void : 32 | R extends (state: S, payload: infer P) => S ? (payload: P) => void : never 33 | 34 | type ActionsFromModelReducerFns> = { 35 | [K in keyof R]: ActionFromModelReducerFn 36 | } 37 | 38 | type ActionFromModelEffectFn = 39 | R extends () => void ? () => void : 40 | R extends (payload: infer P) => void ? (payload: P) => void : never 41 | 42 | type ActionsFromModelEffectFns = { 43 | [K in keyof R]: ActionFromModelEffectFn 44 | } 45 | 46 | type ModelDispatch, E extends EffectFns> = ActionsFromModelReducerFns & ActionsFromModelEffectFns 47 | 48 | export declare function createModel, E extends EffectFns>(model: Model): Model -------------------------------------------------------------------------------- /typings/modelStore.d.ts: -------------------------------------------------------------------------------- 1 | import { Model } from './model' 2 | import { Models, ModelsDispatch, ModelsState } from './models' 3 | import { Store, Dispatch } from './store' 4 | 5 | export interface Plugin { 6 | // if the plugin adds any state to the store, it can define it's own model 7 | // which will be merged together with the application-defined models ... 8 | model?: M 9 | 10 | // NOTE: this is *NOT* the same model as above - this is called for each 11 | // model in the store as part of the setup, to give a plugin chance to do 12 | // whatever it needs to do based on each one. 13 | onModel?(store: ModelStore, name: string, model: M): void 14 | 15 | onStore?(store: ModelStore): void 16 | } 17 | 18 | export interface Plugins { 19 | [name: string]: Plugin 20 | } 21 | 22 | type ExtractPluginModel

= P['model'] 23 | 24 | type ExtractModelsFromPlugins

= { 25 | [K in keyof P]: ExtractPluginModel 26 | } 27 | 28 | type KeysOfPluginsWithModels

= { 29 | [K in keyof P]: undefined extends ExtractPluginModel ? never : K 30 | }[keyof P] 31 | 32 | type PluginsModels

= P extends Plugins ? ExtractModelsFromPlugins>> : unknown 33 | 34 | type ExtractConfigPlugins = C['plugins'] 35 | 36 | type ExtractConfigModels = C['models'] 37 | 38 | type ConfigModels = ExtractConfigModels & PluginsModels> 39 | 40 | export interface Config { 41 | models: Models 42 | plugins?: Plugins 43 | state?: any 44 | } 45 | 46 | interface ModelStore extends Store> { 47 | dispatch: ModelsDispatch & Dispatch 48 | } 49 | 50 | export declare function createStore(config: C): ModelStore> 51 | 52 | export type StoreState = ModelsState> 53 | 54 | export type StoreDispatch = ModelsDispatch> -------------------------------------------------------------------------------- /typings/models.d.ts: -------------------------------------------------------------------------------- 1 | import { Model, ModelDispatch } from './model' 2 | 3 | interface Models { 4 | [name: string]: Model 5 | } 6 | 7 | type ModelsState = { 8 | [K in keyof M]: M[K] extends Model ? S : never 9 | } 10 | 11 | type ModelsDispatch = { 12 | [K in keyof M]: M[K] extends Model ? ModelDispatch : never 13 | } 14 | -------------------------------------------------------------------------------- /typings/persist.d.ts: -------------------------------------------------------------------------------- 1 | import { Action, Store } from './store' 2 | 3 | export interface PersistOptions { 4 | // name sets the state key to use, useful in development to avoid conflict 5 | // with other apps. Default is to use the app location hostname 6 | name: string 7 | 8 | // provide a hook where the serialization could be provided, e.g. by using 9 | // something like https://github.com/KilledByAPixel/JSONCrush. Default is 10 | // the JSON serializer 11 | serializer: { 12 | parse(text: string): any 13 | stringify(value: any): string 14 | } 15 | 16 | // provide a hook where the storage can be replaced. Default is localStorage 17 | storage: { 18 | getItem(name: string): string | null 19 | setItem(name: string, value: string): void 20 | } 21 | 22 | // filter predicate allows control over whether to persist state based on 23 | // the action. Default is to trigger persistence after all actions 24 | filter: (action: Action) => boolean 25 | 26 | // persist allows transforming the state to only persist part of it. 27 | // Default is to persist complete state 28 | persist: (state: S) => Partial 29 | 30 | // delay introduces a delay before the save is performed. If another persist 31 | // is triggered before it expires, the previous persist is cancelled and a 32 | // new one scheduled. This can save doing too many persist operations by 33 | // debouncing the triggering. Default is 0ms 34 | delay: number 35 | 36 | // TODO: version for updates, expiry etc... 37 | } 38 | 39 | export declare function persist(store: T, options?: Partial>): T -------------------------------------------------------------------------------- /typings/routing.d.ts: -------------------------------------------------------------------------------- 1 | import { Result, Matcher } from "@captaincodeman/router" 2 | 3 | import { Model } from "./model" 4 | import { Plugin } from './modelStore' 5 | 6 | type RoutingReducers = { 7 | change: (state: any, payload: RoutingState) => RoutingState 8 | } 9 | 10 | type RoutingEffects = { 11 | back: () => void 12 | forward: () => void 13 | go: (payload: number) => void 14 | push: (href: string) => void 15 | replace: (href: string) => void 16 | } 17 | 18 | interface RoutingPlugin extends Plugin { 19 | // if the plugin adds any state to the store, it can define it's own model 20 | // which will be merged together with the application-defined models ... 21 | model: Model, RoutingReducers, RoutingEffects> 22 | } 23 | 24 | export declare function routingPlugin(router: Matcher, options?: Partial>): RoutingPlugin 25 | 26 | export interface RoutingState extends NonNullable> { 27 | queries?: { 28 | [key: string]: string | string[] 29 | } 30 | } 31 | 32 | export interface RoutingDispatch { 33 | change(payload: RoutingState): void 34 | back(): void 35 | forward(): void 36 | go(payload: number): void 37 | push(href: string): void 38 | replace(href: string): void 39 | } 40 | 41 | export interface RoutingOptions { 42 | transform: (result: Result) => RoutingState 43 | } 44 | 45 | export function withQuerystring(result: Result): RoutingState 46 | 47 | export const routingChange = 'routing/change' 48 | -------------------------------------------------------------------------------- /typings/store.d.ts: -------------------------------------------------------------------------------- 1 | export const dispatchEvent = 'action' 2 | export const stateEvent = 'state' 3 | 4 | export declare class Store extends EventTarget { 5 | constructor(state: S, reducer: Reducer) 6 | dispatch: Dispatch 7 | reducer: Reducer 8 | state: S 9 | } 10 | 11 | export interface Action

{ type?: string, payload?: P } 12 | 13 | export interface ActionEvent { action: Action } 14 | 15 | export type GetState = () => S 16 | 17 | export type Dispatch = (action: A) => any 18 | 19 | export type Reducer = (state: S, action: A) => S 20 | 21 | export type Reducers = { [key: string]: Reducer } 22 | 23 | type ReducerState = R extends Reducer ? S : never 24 | 25 | export type State = { 26 | [K in keyof R]: ReducerState 27 | } 28 | -------------------------------------------------------------------------------- /typings/thunk.d.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, GetState, Store } from './store' 2 | 3 | export type ThunkAction = (dispatch: Dispatch, getState: GetState) => void 4 | 5 | export declare function thunk(store: T): T --------------------------------------------------------------------------------