Dynalon / reactive-state

Redux-clone build with strict typing and RxJS down to its core. Wrist-friendly, no boilerplate or endless switch statements
MIT License
138 stars 7 forks source link

Allow string-based dispatch for side-effects #15

Open zeroooooooo opened 5 years ago

zeroooooooo commented 5 years ago

Thanks for the great work.

I try to dispatch the action manually but it call the reducer directly, the action didn't been called. I check out the source and see every addReducer will return the subscribe with the current action but dont't know why it isn't been called.

code example(edit from the demo) as follows:

` const dispatchProxy = async (payload: any, namespace: string) => {

if (namespace) {
  store.dispatch(`${namespace}/${payload.type}`, payload.data);
} else {   
 slice.dispatch(payload.type, payload.data);  // this won't work 
  // getSampleImage.next(payload.data); // this will work
}

}

const actionMap: ActionMap = {

onGetNewSampleImage: getSampleImage,
dispatchProxy

} `

Dynalon commented 5 years ago

Can you post the code that you used to register the actual action via .addReducer()? Since you have some kind of custom ${namespace}/ prefixing, maybe the string is not the expected one?

You could also check with the Redux Devtools which actions was dispatched when you .next() on the observable directly. Do the strings match?

Note: I added the string-based action dispatched only for some special cases, like using the devtools, replaying/serializing actions between frames or over the network etc. I highly advice to use observable-based action dispatch, except for the cases where this is not possible.

zeroooooooo commented 5 years ago

Hi, @Dynalon, sorry for the late response.

I post a simplified demo here.

The code I used to register action is the same as the example code as follows: connect.tsx dogs.tsx

import { Store } from 'reactive-state';
import { ActionMap, connect } from 'reactive-state/react';
import { defer, Subject } from 'rxjs';
import { filter, map, switchMap } from 'rxjs/operators';
import { Dogs } from './dogs';

interface Dogsslice {
  breedNames: string[];
  selectedBreed?: string;
  breedSampleImageUrl?: string;
}

// Use an observable as action to fetch a list of all available dog breeds
const fetchBreedNames = defer(() => fetch('https://dog.ceo/api/breeds/list')).pipe(
  switchMap(response => response.json()),
  map(body => body.message as string[])
);

// use a Subject that will trigger fetching an example image by a given breed name
const getSampleImage = new Subject<string>();

// the actual logic that will fetch the image when the action is dispatched
const fetchSampleImage = getSampleImage.pipe(
  switchMap(breedName => {
    return fetch(`https://dog.ceo/api/breed/${breedName}/images/random`);
  }),
  switchMap(response => response.json()),
  map(body => body.message as string)
);

export default connect(Dogs, (store: Store<Dogsslice & {[property: string]: any}>) => {
  const branchName = 'branch:dogs';
  const slice = store.createSlice(branchName, { breedNames: [] });

  // add reducers/action pairs - note that the string names are only for debugging purposes in devtools and
  // not required
  slice.addReducer(fetchBreedNames, (state, breedNames) => ({ ...state, breedNames }), `${branchName}/FETCH_BREED_NAMES`);
  slice.addReducer(fetchSampleImage, (state, imageUrl) => ({ ...state, breedSampleImageUrl: imageUrl }), `${branchName}/FETCH_SAMPLE_IMAGE`);
  slice.addReducer(getSampleImage, (state, breedName) => ({ ...state, selectedBreed: breedName }), `${branchName}/SELECT_BREED_NAME`);

  const props = slice.watch().pipe(
    filter(state => state.breedNames.length > 0),
    map(state => {
      return {
        breedNames: state.breedNames,
        selectedBreed: state.selectedBreed,
        breedSampleImageUrl: state.breedSampleImageUrl
      };
    })
  );

  const dispatchProxy = async (payload: any, namespace: string) => {
    const actionName = namespace ? `${namespace}/${payload.type}` : `${branchName}/${payload.type}`;

    slice.dispatch(actionName, payload.data);
    // getSampleImage.next(payload.data);
  };

  const actionMap: ActionMap<Dogs> = {
    onGetNewSampleImage: getSampleImage,
    dispatchProxy
  };

  // the store we got as 1st argument is a clone and automatically destroyed when the
  // connected component gets unmounted. Upon destroy, all action/reducer pairs that we added inside
  // this function become inactive. This also applies to all child/slice stores created from the clone -
  // they will be destroyed, too, upon umount. To show the auto-destroy feature, we log to the console:
  slice.destroyed.subscribe(() => console.info('Dog slice got destroyed, all action/reducer pairs on this slice were removed'));

  return {
    actionMap,
    props
  };
});

the diff is I add a function to proxy the dispatch as follows:

  const dispatchProxy = async (payload: any, namespace: string) => {
    const actionName = namespace ? `${namespace}/${payload.type}` : `${branchName}/${payload.type}`;

    slice.dispatch(actionName, payload.data);
    // getSampleImage.next(payload.data);
  };

  const actionMap: ActionMap<Dogs> = {
    onGetNewSampleImage: getSampleImage,
    dispatchProxy
  };

this function accept the action from the component and help to proxy the action by type,

  private test = () => {
    const t = ['affenpinscher', 'african', 'airedale'];
    this.props.dispatchProxy({
      type: 'FETCH_SAMPLE_IMAGE',
      data: t[~~(Math.random() * 10 % 3)]
    });
    // this.props.onGetNewSampleImage(t[~~(Math.random() * 10 % 3)]);
  }

but the result is if I call the onGetNewSampleImage it will trigger 'SELECT_BREED_NAME' and 'FETCH_SAMPLE_IMAGE', if I call the dispatch function it will trigger reducer directly. the async action didn't been call.

I saw the annotation in the source code, and know it is not adviced to used manully dispatch extensively.but I want to do something as follows:

so I think dispatch manully is a necessary feature.

I don't know if I explained it clearly. If you need more specific details, I can try to explain the application's organization and the reason.

Thanks.

Dynalon commented 5 years ago

First of all: There is nothing wrong with your dispatch logic. Upon clicking the button, the FETCH_SAMPLE_IMAGE action is correcty dispatched, and you can even see a state change that puts the dog's race into the state. See the redux devtools:

Bug

The issue is that you are dispatching a dog's race name instead of an image url - the FETCH_SAMPLE_IMAGE action requires a URL as a payload.

Now, when you dispatch through an observable, your are calling .next('african') on getSampleImage subject - this is not a string-based action! It is just a Subject that is used in an Observable pipeline:

// This is a Subject that is NOT an action - this is used in the next line on fetchSampleImage to
// build a pipeline!
const getSampleImage = new Subject<string>();

// the actual logic that will fetch the image when getSampleImage is .next()'ed
const fetchSampleImage = getSampleImage.pipe(
  switchMap(breedName => {
    return fetch(`https://dog.ceo/api/breed/${breedName}/images/random`);
  }),
  switchMap(response => response.json()),
  map(body => body.message as string)
);

Here you can see, getSampleImage is piped to custom logic which maps the dog's race to a url, fetches a JSON response, and gets a new url that points to an actual image of the dog.

In your case I see several options that you have:

  1. export the getSampleImage Subject and import {} it elsewhere. This is basically my suggestion not to use string-based action dispatches at all, but rather reference the Observable. You could also put all your actions that should be called from other modules into a single file and import from there.

  2. If you still want to stay with string-based action dispatch (this can be wise to decouple modules!), you could add a "reducer" with a side-effect and a string name so you can dispatch it:

    slice.addReducer('branch:dogs/GET_SAMPLE_IMAGE', (state, race: string) => {
    // this is a side-effect
    getSampleImage.next(race);
    // return an unaltered state.
    return state;
    });

    And dispatch GET_SAMPLE_IMAGE now. This is somewhat ugly, as reducers are supposed to be side-effect free. I am thinking of adding a .addSideEffect() function in future releases of reactive-state to cover this. I create a PR for this change: https://github.com/zeroooooooo/helloworld/pull/1

  3. You could also use a global pubsub messenger (maybe use EventEmitter for the browser) and publish actions over this global message bus. In your modules, you would subscribe to the string-based events on the EventEmitter, and call .next(payload) based on the pubsub payload. You wouldn't be using string-based actions of reactive-state at all, but rather use the string-based pubsub only. This not only would decouple your application using strings, it would also decouple your modules from reactive-state as they would only use the EventEmitter. This is a very fancy architectural pattern, that I think you should only consider for large-scale applications.

I'd recommend option (1) - do not use string-based action dispatch. But if you absoluteley need this, go with option (2).

zeroooooooo commented 5 years ago

Thanks a lot for the replying and the suggestions. @Dynalon

I thought dispatch would trigger the observable and through the pipeline, I got it wrong...

addSideEffect should be a good choice.

export and import the actions is a useful method to solve the problem. but with the application growing, there will be much more modules, and the relations between the slice may be complex. and many boilerplate code must be written.

It's a common problem to the application I've developed, so I try to find a common pattern for the most application and in different frameworks. and it seems make a break through by using vue with vuex recently. when I met this state management tool, I think this may solve the problem in different frameworks. great work!

what I want to do is something as follows:

import { Service } from './service/base';
export default {
  name: 'Coke Factory',
  shareState: {
    cokeCans: {
      type: 'string',
      default: ''
    },
    cokeWater: {
      type: 'string',
      default: ''
    },
    coke: {
      type: 'string',
      default: '',
      action: Service.productCoke
    }
  },
  componentsList: [
    {
      name: 'Hello',
      globalState: [
        'root/userInfo',
        'trunk:counter/counter',
        'branch:other/iron'
      ]
    },
    {
      name: 'World'
    }
  ]
};

Think of the pages are as containers, they are make up of the components from the configuration, and we have a HOC which can automatically created the actions and the reducers by the describe from the configuration(shareState key is the content, it can tell which state should be share in the page), and when dynamic load the related page, it will register the whole thing. and pass the state as props to the page's components. and when component wants to change the share state, it needs to dispatch an action(by type and payload data), and the page will proxy the action, and then state will be changed, then components which subscribe the state will be reRender. So I think the string-based dispatch is better for this situation.

page = HOC(Mixins in vue) + configuration

I think it's a good solution for me. It has some advantages as follows

This is most of my thoughts.

I think this state management is a great direction. thanks again!

Dynalon commented 5 years ago

Thanks for your detailed feedback, always good to hear what problems other developers face and how they come up with solutions. That helps making reactive-state better in the future!

Let me give you some insights about the issues I see with string-based action dispatch (which I disliked in Redux and was the reason to come up with reactive-state in the first place!):

  1. No type safety. Both Redux and the solution that you came up with using the dispatchProxy completely destroy type safety. I can dispatch a stringto the FETCH_SAMPLE_IMAGE action, or a number, an Object or whatnot. The compiler will not report an error. If you have a large application and you refactor the type of the action payload, you must remember to change the type wherever you dispatch that action. When you use the Subject, the compiler will always complain when you try to dispatch the wrong type. If you refactor the type of Subject<string> to Subject<Url>, the compiler will ultimately tell you all the lines of code you need to change, as it knows where you called .next() with the wrong type on that subject.
  2. Unclear dependencies. When using string-based dispatch, you have no idea which part of the code actually dispatch it. You would need to deep-search through the sourcecode. Worse: If you remove a reducer that was added via .addReducer(), there might be areas in the code still dispatching the action. Again, the compiler will not complain. If you were using a Subject, you would most likely remove the Subject along with the addReducer() and the compiler will error on all places that still try to dispatch an action to trigger this reducer. (This is especially true if you use the noUnusedLocals typescript compiler flag, as it will tell you that the unused Subject needs removal)
  3. Uncler encapsulation / visibility. You can have a set of Subject actions that are module internal. That is, you will not export them beyond module boundaries - that is you will not make them part of the API of the module that is meant to be consumed by other entities. You will only export those Subjects that are meant to be dispatched by other modules. With the string based approach, you completely lose this separation. Now, you came up with a {namespace}/{branch}:{action} approach to kind of solve this. But the import/export and the structure of modules in JavaScript/TypeScript is intended to be used for this encapsulation. Your custom string-prefix approach is a newly introduced semantic, that I see as a kind of unnecessary abstraction.

export and import the actions is a useful method to solve the problem. but with the application growing, there will be much more modules, and the relations between the slice may be complex. and many boilerplate code must be written.

I don't see any boilerplate. The boilerplate will be magic string constant that exist throughout your application. You can put all Subjects that trigger registered action/reducer in a root-level file, or your modules entry point. All you need to do is to import {} it and call .next() on it. Benefit: You will see by the import path which module requires what other module. So your inter-module dependencies are clearly expressed. You cannot remove Module A and have Module B still present, if B dispatches an action to A - this will result in a compiler error. If you want optional modules - that is module B might dispatch to A if its available at runtime, you can use a dynamic import() statement: const action = import('othermodule/A').someAction statement to test for the module (and use try/catch or .then/.catch on a promise to handle presence/absence of the module). Again, 100% compiler typesafety.

To sum up the benefit of strictly using Observables/Subjects for action dispatch:

  1. Enforced payload type safety
  2. Clear overview where a specific action is dispatched with compiler safety net
  3. Clearly expressed internal/external actions: Internal (private) actions only can be dispatched inside the same module, external/exported actions (=export const myAction = new Subject<string>()) are meant to be called from outside the module

Managing / breaking down the state for modules

Now, as for your root/branch/trunk separation of the state, I want to point you to a - sadly not yed documented feature - of reactive-state. I had similar issues with structuring the state, and even using .createSlice() turned out to be not enough for truly decoupled states between individual components/modules that occur in large-scale applications. Thats why I created the concept of projections and added it last month silently to the reactive-state codebase (present in reactive-state >=3.0).

As it is yet undocumented, here only a brief overview. The idea is not specific to reactive-state but could be used in other state containers. Although, I am not aware of any that uses this concept and I came up with it myself.

Right now, .createSlice() only operates on a property of a state, using a string/key to identify the property. For example: new Store({ subslice: "foo" }).createSlice("subslice"). The concept of projections are way more powerful: Given a state of type A you can create any kind of slice of type B by specifying a forward- and a backwardprojection. The forward projection is a function that take a state of type A and would return a state of type B. The backward projection reverses this, as it is a function that takes a state of type B as argument, and returns a state of type A as a result.

Here is some code to explain:

export interface Customer {
    customerId: string
    customerOrders: { orderId: string, product: string }[] 
}
export type Order = { customerId: string, orderId: string, product: string }

const customerStore: Store<Customer> = Store.create({
    customerId: "1",
    customerOrders: [
        { orderId: "1", product: "banana" },
        { orderId: "2", product: "apple" }
    ]
})

const forwardProjection: (customer: Customer) => Order[] = (customer) => {
    return customer.customerOrders.map(order => ({ customerId: customer.customerId, ...order }))
};
const backwardProjection: (orders: Order[]) => Customer = (orders) => {
    return {
        customerId: orders[0].customerId,
        customerOrders: orders.map(({ orderId, product }) => ({ orderId, product }))
    }
}

const orderStore: Store<Order[]> = customerStore.createProjection(forwardProjection, backwardProjection);

Now, I know the example is a bit lightweight and the code is probably hard to grasp but it shows the concept. We have two different state, Customer and Order[]. You can add reducers on both, the customerStore and the orderStore. Both reducer functions would have different signatures, as the state type is different for both stores. But since they are linked and automatically kept in-sync, whenever a reducer changes either store, the other store will reflect this change and fire all updates etc.

A slice state can be anything you want, not only a property of the (root) state. As long as you can transform the "parent" state A into B (=forward projection) and reverse this action (=backward projection) you can perfectly mix-and-match all data that you need for your module on the state. This allows maximum flexibility when designing the state object.

I know the concept of projections is very advanced and probably overkill for 90% of the applications, but it might be of use to anyone. Actually, the createSlice function is implemented already using createProjection internally: https://github.com/Dynalon/reactive-state/blob/master/src/store.ts#L139 and plenty of tests exists that might help in understanding it: https://github.com/Dynalon/reactive-state/blob/master/test/test_slicing.ts#L137

Dynalon commented 5 years ago

Note to self: This issue is still open and considered "enhancement". But I still need to decide whether a .addSideEffect() is a good idea, as it might promote string-based actions (for the wrong reasons). Community feedback and discussion welcome, of course.