Coder Social home page Coder Social logo

useeffectreducer's Introduction

useEffectReducer

A React hook for managing side-effects in your reducers.

Inspired by the useReducerWithEmitEffect hook idea by Sophie Alpert.

If you know how to useReducer, you already know how to useEffectReducer.

πŸ’» CodeSandbox example: Dog Fetcher with useEffectReducer

Installation

Install it:

npm install use-effect-reducer

Import it:

import { useEffectReducer } from 'use-effect-reducer';

Create an effect reducer:

const someEffectReducer = (state, event, exec) => {
  // execute effects like this:
  exec(() => {/* ... */});

  // or parameterized (better):
  exec({ type: 'fetchUser', user: event.user });

  // and treat this like a normal reducer!
  // ...

  return state;
};

Use it:

// ...
const [state, dispatch] = useEffectReducer(someEffectReducer, initialState, {
  // implementation of effects
});

// Just like useReducer:
dispatch({ type: 'FETCH', user: 'Sophie' });

Isn't this unsafe?

No - internally, useEffectReducer (as the name implies) is abstracting this pattern:

// pseudocode
const myReducer = ([state], event) => {
  const effects = [];
  const exec = (effect) => effects.push(effect);
  
  const nextState = // calculate next state
  
  return [nextState, effects];
}

// in your component
const [[state, effects], dispatch] = useReducer(myReducer);

useEffect(() => {
  effects.forEach(effect => {
    // execute the effect
  });
}, [effects]);

Instead of being implicit about which effects are executed and when they are executed, you make this explicit in the "effect reducer" with the helper exec function. Then, the useEffectReducer hook will take the pending effects and properly execute them within a useEffect() hook.

Quick Start

An "effect reducer" takes 3 arguments:

  1. state - the current state
  2. event - the event that was dispatched to the reducer
  3. exec - a function that captures effects to be executed and returns an effect entity that allows you to control the effect
import { useEffectReducer } from 'use-effect-reducer';

// I know, I know, yet another counter example
const countReducer = (state, event, exec) => {
  switch (event.type) {
    case 'INC':
      exec(() => {
        // "Execute" a side-effect here
        console.log('Going up!');
      });

      return {
        ...state,
        count: state.count + 1,
      };

    default:
      return state;
  }
};

const App = () => {
  const [state, dispatch] = useEffectReducer(countReducer, { count: 0 });

  return (
    <div>
      <output>Count: {state.count}</output>
      <button onClick={() => dispatch('INC')}>Increment</button>
    </div>
  );
};

Named Effects

A better way to make reusable effect reducers is to have effects that are named and parameterized. This is done by running exec(...) an effect object (instead of a function) and specifying that named effect's implementation as the 3rd argument to useEffectReducer(reducer, initial, effectMap).

const fetchEffectReducer = (state, event, exec) => {
  switch (event.type) {
    case 'FETCH':
      // Capture a named effect to be executed
      exec({ type: 'fetchFromAPI', user: event.user });

      return {
        ...state,
        status: 'fetching',
      };
    case 'RESOLVE':
      return {
        status: 'fulfilled',
        user: event.data,
      };
    default:
      return state;
  }
};

const initialState = { status: 'idle', user: undefined };

const fetchFromAPIEffect = (_, effect, dispatch) => {
  fetch(`/api/users/${effect.user}`)
    .then(res => res.json())
    .then(data => {
      dispatch({
        type: 'RESOLVE',
        data,
      });
    });
};

const Fetcher = () => {
  const [state, dispatch] = useEffectReducer(fetchEffectReducer, initialState, {
    // Specify how effects are implemented
    fetchFromAPI: fetchFromAPIEffect,
  });

  return (
    <button
      onClick={() => {
        dispatch({ type: 'FETCH', user: 42 });
      }}
    >
      Fetch user
    </div>
  );
};

Effect Implementations

An effect implementation is a function that takes 3 arguments:

  1. The state at the time the effect was executed with exec(effect)
  2. The event object that triggered the effect
  3. The effect reducer's dispatch function to dispatch events back to it. This enables dispatching within effects in the effectMap if it is written outside of the scope of your component. If your effects require access to variables and functions in the scope of your component, write your effectMap there.

The effect implementation should return a disposal function that cleans up the effect:

// Effect defined inline
exec(() => {
  const id = setTimeout(() => {
    // do some delayed side-effect
  }, 1000);

  // disposal function
  return () => {
    clearTimeout(id);
  };
});
// Parameterized effect implementation
// (in the effect reducer)
exec({ type: 'doDelayedEffect' });

// ...

// (in the component)
const [state, dispatch] = useEffectReducer(someReducer, initialState, {
  doDelayedEffect: () => {
    const id = setTimeout(() => {
      // do some delayed side-effect
    }, 1000);

    // disposal function
    return () => {
      clearTimeout(id);
    };
  },
});

Initial Effects

The 2nd argument to useEffectReducer(state, initialState) can either be a static initialState or a function that takes in an effect exec function and returns the initialState:

const fetchReducer = (state, event) => {
  if (event.type === 'RESOLVE') {
    return {
      ...state,
      data: event.data,
    };
  }

  return state;
};

const getInitialState = exec => {
  exec({ type: 'fetchData', someQuery: '*' });

  return { data: null };
};

// (in the component)
const [state, dispatch] = useEffectReducer(fetchReducer, getInitialState, {
  fetchData(_, { someQuery }) {
    fetch(`/some/api?${someQuery}`)
      .then(res => res.json())
      .then(data => {
        dispatch({
          type: 'RESOLVE',
          data,
        });
      });
  },
});

Effect Entities

The exec(effect) function returns an effect entity, which is a special object that represents the running effect. These objects can be stored directly in the reducer's state:

const someReducer = (state, event, exec) => {
  // ...

  return {
    ...state,
    // state.someEffect is now an effect entity
    someEffect: exec(() => {
      /* ... */
    }),
  };
};

The advantage of having a reference to the effect (via the returned effect entity) is that you can explicitly stop those effects:

const someReducer = (state, event, exec) => {
  // ...

  // Stop an effect entity
  exec.stop(state.someEffect);

  return {
    ...state,
    // state.someEffect is no longer needed
    someEffect: undefined,
  };
};

Effect Cleanup

Instead of implicitly relying on arbitrary values in a dependency array changing to stop an effect (as you would with useEffect), effects can be explicitly stopped using exec.stop(entity), where entity is the effect entity returned from initially calling exec(effect):

const timerReducer = (state, event, exec) => {
  if (event.type === 'START') {
    return {
      ...state,
      timer: exec(() => {
        const id = setTimeout(() => {
          // Do some delayed effect
        }, 1000);

        // Disposal function - will be called when
        // effect entity is stopped
        return () => {
          clearTimeout(id);
        };
      }),
    };
  } else if (event.type === 'STOP') {
    // Stop the effect entity
    exec.stop(state.timer);

    return state;
  }

  return state;
};

All running effect entities will automatically be stopped when the component unmounts.

Replacing Effects

If you want to replace an effect with another (likely similar) effect, instead of calling exec.stop(entity) and calling exec(effect) to manually replace an effect, you can call exec.replace(entity, effect) as a shorthand:

const doSomeDelay = () => {
  const id = setTimeout(() => {
    // do some delayed effect
  }, delay);

  return () => {
    clearTimeout(id);
  };
};

const timerReducer = (state, event, exec) => {
  if (event.type === 'START') {
    return {
      ...state,
      timer: exec(() => doSomeDelay()),
    };
  } else if (event.type === 'LAP') {
    // Replace the currently running effect represented by `state.timer`
    // with a new effect
    return {
      ...state,
      timer: exec.replace(state.timer, () => doSomeDelay()),
    };
  } else if (event.type === 'STOP') {
    // Stop the effect entity
    exec.stop(state.timer);

    return state;
  }

  return state;
};

String Events

The events handled by the effect reducers are intended to be event objects with a type property; e.g., { type: 'FETCH', other: 'data' }. For events without payload, you can dispatch the event type alone, which will be converted to an event object inside the effect reducer:

// dispatched as `{ type: 'INC' }`
// and is the same as `dispatch({ type: 'INC' })`
dispatch('INC');

API

useEffectReducer hook

The useEffectReducer hook takes the same first 2 arguments as the built-in useReducer hook, and returns the current state returned from the effect reducer, as well as a dispatch function for sending events to the reducer.

const SomeComponent = () => {
  const [state, dispatch] = useEffectReducer(someEffectReducer, initialState);

  // ...
};

The 2nd argument to useEffectReducer(...) can either be a static initialState or a function that takes in exec and returns an initialState (with executed initial effects). See Initial Effects for more information.

const SomeComponent = () => {
  const [state, dispatch] = useEffectReducer(
    someEffectReducer,
    exec => {
      exec({ type: 'someEffect' });
      return someInitialState;
    },
    {
      someEffect(state, effect) {
        // ...
      },
    }
  );

  // ...
};

Additionally, the useEffectReducer hook takes a 3rd argument, which is the implementation details for named effects:

const SomeComponent = () => {
  const [state, dispatch] = useEffectReducer(someEffectReducer, initialState, {
    log: (state, effect, dispatch) => {
      console.log(state);
    },
  });

  // ...
};

exec(effect)

Used in an effect reducer, exec(effect) queues the effect for execution and returns an effect entity.

The effect can either be an effect object:

// ...
const entity = exec({
  type: 'alert',
  message: 'hello',
});

Or it can be an inline effect implementation:

// ...
const entity = exec(() => {
  alert('hello');
});

exec.stop(entity)

Used in an effect reducer, exec.stop(entity) stops the effect represented by the entity. Returns void.

// Queues the effect entity for disposal
exec.stop(someEntity);

exec.replace(entity, effect)

Used in an effect reducer, exec.replace(entity, effect) does two things:

  1. Queues the entity for disposal (same as calling exec.stop(entity))
  2. Returns a new effect entity that represents the effect that replaces the previous entity.

TypeScript

The effect reducer can be specified as an EffectReducer<TState, TEvent, TEffect>, where the generic types are:

  • The state type returned from the reducer
  • The event object type that can be dispatched to the reducer
  • The effect object type that can be executed
import { useEffectReducer, EffectReducer } from 'use-effect-reducer';

interface User {
  name: string;
}

type FetchState =
  | {
      status: 'idle';
      user: undefined;
    }
  | {
      status: 'fetching';
      user: User | undefined;
    }
  | {
      status: 'fulfilled';
      user: User;
    };

type FetchEvent =
  | {
      type: 'FETCH';
      user: string;
    }
  | {
      type: 'RESOLVE';
      data: User;
    };

type FetchEffect = {
  type: 'fetchFromAPI';
  user: string;
};

const fetchEffectReducer: EffectReducer<FetchState, FetchEvent, FetchEffect> = (
  state,
  event,
  exec
) => {
  switch (event.type) {
    case 'FETCH':
    // State, event, and effect types will be inferred!

    // Also you should probably switch on
    // `state.status` first ;-)

    // ...

    default:
      return state;
  }
};

useeffectreducer's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

useeffectreducer's Issues

Non-memoized / ref'd reducer runs into double dispatch issue when used in custom hook

Hello! First off, love this library so much, it's made my code so much more clean.

Simplified reproduction: https://codesandbox.io/s/red-https-bxpo0?file=/src/App.tsx

But anyways, I've run into this scenario recently and was able to resolve by resolving this locally - but, to be honest, I'm not really sure of the React internals enough to PR this + test. Anyways, the scenario

  1. I want to use useEffectReducer in my own hook and export out it's dispatch so that other components can programmatically send out events to the reducer.
  2. However, when I leverage the dispatch it emits events twice.
  3. To resolve, you can useMemo the wrappedReducer .
const memoReducer = useMemo(() => wrappedReducer, []);

const [[state, effectStateEntityTuples, entitiesToStop], dispatch] = useReducer(
    memoReducer,
    initialStateAndEffects
 );

[Question] How to β€œinject” dynamic dependencies within effects?

I'm wondering if it's ok to call dynamic dependencies within effects:

const history = useHistory()
const queryClient = useQueryClient()

const [state, dispatch] = useEffectReducer(reducer, initialState, {
  invalidateUsers: () => queryClient.invalidateQueries("users"),
  redirectToHome: () => history.push("/")
})

Let's suppose that history and queryClient will keep changing between rerenders, is my service implementation going to work?

Unconditionally flushing effects is unsafe

Hi! This implementation has a bug that can cause effects to be permanently skipped.

The special flush event clears all pending effects from the queue:

if (event === flushEffectsSymbol) {
// Record that effects have already been executed
return [state, []];
}

This seems fine because event is dispatched after clearing all effects in the queue:

useEffect(() => {
if (stateEffectTuples.length) {
stateEffectTuples.forEach(([stateForEffect, effects]) => {

dispatch(flushEffectsSymbol);

The problem is that this effect closes over stateEffectTuples. It's possible that new effects will be added to the queue in between the render and the execution of the effect. These effects won't be executed in the effect because of the closure, but they will be cleared by the flush event. So they will effectively be skipped.

Here's a sandbox demonstrating the problem. Click "increment" and notice that the state correctly updates to 2, but the console has only printed INC 0. We'd expect it to also print INC 1. The latter effect was skipped because the second increment was added to the queue after the closure but before the dispatch of the flush event. So the effect gets added to the queue and then cleared without having a chance to run.

One potential solution would be to slice off the first stateEffectTuples.length elements from the state at the time of the update rather than unconditionally returning []. Essentially this is saying "flush the effects that were just run" rather than "flush all effects".

Future Updates / Improvements?

Hello,

I've noticed that the last update to this package was some 3 years ago and that was to the README, with any other change outside of this occurring 4 years ago. Given this and issues being raised such as React 18 support I'm wondering if there are any planned updates or improvements? Currently it looks as if the package is not being maintained.

Thanks!

nice way to use hooks as not intended

the whole library wants to do something like this

function useEffectReducer(notReducer, initialState) {
  const [state, setState] = useState(initialState);
  effects
  const dispatch= (action) => {
    setState(notReducer(state, action, useEffect))
  } 
  return [state, dispatch];
}

but the React will raise error for this piece of code, cause you should not use hooks this way,
so we can add all functions with effect inside a list

function useEffectReducer(notReducer, initialState) {
  const [state, setState] = useState(initialState);
  const effects = useRef([])
  const exec =  (a) => effects.current.push(a);

  const dispatch= (action) => {
    setState(notReducer(state, action, exec))
  } 
  useEffect(() => {
      effects.current.forEach(a => a())
      effects.current = []
  }, [state])

  return [state, dispatch];
}

so now we can run our effects anywhere we want.
what is wrong with this code? say we animate something using jQuery inside our reducer then calling exec inside it or calling an API then after that call exec when the data received,
so you implement a library that everywhere in the codebase you can call useEffect.

do you know how much effort the React team has done so you can't use effects this way?

I think it is better that every action or side effect will be wrapped inside a Task monad or something like that so the reducer will return a tuple of state and effects

also, the reducer is not pure as I said in twitter and you blocked me, I hope you understand that you are an influencer in this community and saying something that is not correct make other people like me in a mistake.

nevertheless, I'm sorry if my behavior was not appropriate I was just wanted to inform you and others like me that our reducer function is not pure.

Can we expect useEffectReducer to work with react 18?

Hey @davidkpiano !

First of all, thank you for this awesome reducer! It's the perfect approach for me and my team right now without getting 100% into xstate (yet).

Anyways, we are planning on upgrading to react 18 soon and I would like to know if I can count on not having effects running multiple times (due to the whole Strict Mode thing).
Don't know if you have tested useEffectReducer with it yet, but if you did, I was hoping to know your results πŸ™

Thank you for your time!

Effect cleanup

Thinking out loud: we need some way to control the cleanup of effects. Right now, useEffectReducer treats effects as fire-and-forget, which is fine for many use-cases, but can introduce memory leaks or undesired behaviors for certain effects that are supposed to have a specific lifetime.

One solution: exec(effect) can return an entity which can be explicitly managed in the reducer state:

const someReducer = (state, event, exec) => {
  if (event.type === 'FETCH') {
    return {
      // ...
      effect: exec({ type: 'fetch', user: 'David' })
    }
  else if (event.type === 'CANCEL') {
    // stop effect
    state.effect.stop();

    return {/* ... */}
  }
}

The effect returned from exec(...) will be an opaque object that (for now) just has a unique id and a .stop() method. This will call the disposal function returned from the effect:

const [state, dispatch] = useEffectReducer(someReducer, initialState, {
  fetch: () => {
    // ...

    return () => {
      // cancel fetch request
    }
  }
});

prevState in effect?

Would it be possible to expose the prevState to our effects as well?

This would allow us to move the decision to run expensive side effects to the effect, instead of having it in the reducer, wrapping the exec call.

Add `dispatch` to the effects arguments

Forked from: https://twitter.com/meijer_s/status/1252252222500622336?s=20

I think it makes sense to pass dispatch to the effect's arguments as well, so that we don't need to declare our effects, or "api" within the scope of the component.

I believe this would also benefit the test-ability of the effects. As I could now just test api and stub dispatch with jest.fn.

const api = {
  fetchDog: async (state, effect, dispatch) => {
    try {
      const data = await fetchRandomDog();
      dispatch({ type: 'RESOLVE', data });
    } catch (error) {
      dispatch({ type: 'REJECT', error });
    }
  },
};

function dogReducer(state, event, exec) {}
const initialState = {};

function DogFetcher() {
  const [state, dispatch] = useEffectReducer(dogReducer, initialState, api);

  // ...
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.