;
7 | }
8 |
9 | interface Model {
10 | todos: TodosModel;
11 | }
12 |
13 | const todos: TodosModel = {
14 | items: [],
15 | add: action(
16 | (state, payload) => {
17 | state.items.push(payload);
18 | },
19 | { immer: true },
20 | ),
21 | clear: action((state) => {
22 | state.items = [];
23 | }),
24 | };
25 |
26 | const model: Model = {
27 | todos,
28 | };
29 |
30 | const store = createStore(model);
31 |
32 | store.dispatch.todos.add('foo');
33 | // @ts-expect-error
34 | store.dispatch.todos.add(1);
35 | // @ts-expect-error
36 | store.dispatch.todos.add();
37 |
38 | store.dispatch.todos.clear();
39 |
--------------------------------------------------------------------------------
/examples/react-native-todo/android/app/src/release/java/com/reactnativetodo/ReactNativeFlipper.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the LICENSE file in the root
5 | * directory of this source tree.
6 | */
7 | package com.reactnativetodo;
8 |
9 | import android.content.Context;
10 | import com.facebook.react.ReactInstanceManager;
11 |
12 | /**
13 | * Class responsible of loading Flipper inside your React Native application. This is the release
14 | * flavor of it so it's empty as we don't want to load Flipper.
15 | */
16 | public class ReactNativeFlipper {
17 | public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
18 | // Do nothing as we don't want to initialize Flipper on Release.
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/state.md:
--------------------------------------------------------------------------------
1 | # State
2 |
3 | Allows you to get a type that represents the state of a model.
4 |
5 | ```typescript
6 | State<
7 | Model extends object = {}
8 | >
9 | ```
10 |
11 | ## Example
12 |
13 | ```typescript
14 | import { State } from 'easy-peasy';
15 | import { StoreModel } from './index';
16 |
17 | type StoreState = State;
18 | ```
19 |
20 | Typically this would only be useful when using the `useStoreState` hook.
21 |
22 | ```typescript
23 | import { useStoreState, State } from 'easy-peasy';
24 | import { StoreModel } from './store';
25 |
26 | function MyComponent() {
27 | const todos = useStoreState(
28 | (state: State) => state.todos.items
29 | );
30 | }
31 | ```
32 |
33 | That being said, we recommend you use the [createTypedHooks](/docs/typescript-api/create-typed-hooks.html) API instead.
--------------------------------------------------------------------------------
/tests/typescript/create-store.ts:
--------------------------------------------------------------------------------
1 | import { action, createStore, EasyPeasyConfig, Action } from 'easy-peasy';
2 |
3 | interface StoreModel {
4 | foo: string;
5 | update: Action;
6 | }
7 |
8 | const model: StoreModel = {
9 | foo: 'bar',
10 | update: action((state, payload) => {
11 | state.foo = payload;
12 | }),
13 | };
14 |
15 | const storeWithoutConfig = createStore(model);
16 |
17 | storeWithoutConfig.getMockedActions().length;
18 | storeWithoutConfig.clearMockedActions();
19 | storeWithoutConfig.getState().foo;
20 | storeWithoutConfig.getActions().update('bar');
21 |
22 | const config: EasyPeasyConfig = {
23 | mockActions: true,
24 | };
25 | const storeWithConfig = createStore(model, config);
26 |
27 | storeWithConfig.getMockedActions().length;
28 | storeWithoutConfig.clearMockedActions();
29 | storeWithConfig.getActions().update('bar');
30 |
--------------------------------------------------------------------------------
/src/migrations.js:
--------------------------------------------------------------------------------
1 | import { produce, setAutoFreeze } from 'immer';
2 |
3 | export const migrate = (
4 | data,
5 | migrations,
6 | ) => {
7 | setAutoFreeze(false);
8 |
9 | let version = data._migrationVersion ?? 0;
10 | const toVersion = migrations.migrationVersion
11 |
12 | if (typeof version !== "number" || typeof toVersion !== 'number') {
13 | throw new Error('No migration version found');
14 | }
15 |
16 | while (version < toVersion) {
17 | const nextVersion = version + 1;
18 | const migrator = migrations[nextVersion];
19 |
20 | if (!migrator) {
21 | throw new Error(`No migrator found for \`migrationVersion\` ${nextVersion}`);
22 | }
23 |
24 | data = produce(data, migrator);
25 | data._migrationVersion = nextVersion;
26 | version = data._migrationVersion;
27 | }
28 |
29 | setAutoFreeze(true);
30 | return data;
31 | }
32 |
--------------------------------------------------------------------------------
/examples/react-native-todo/ios/ReactNativeTodoTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 |
24 |
25 |
--------------------------------------------------------------------------------
/examples/nextjs-todo/README.md:
--------------------------------------------------------------------------------
1 | # Simple todo example with Next.js
2 |
3 | This is a [Next.js](https://nextjs.org/) example bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
4 |
5 | This is a clone of [`simple-todo`](../simple-todo/), modified to be compatible with Next.js.
6 |
7 | ## Getting Started
8 |
9 | First, run the development server:
10 |
11 | ```bash
12 | yarn dev
13 | ```
14 |
15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
16 |
17 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
18 |
19 | The `easy-peasy` store & models are located in the `store` folder. All pages (in this example, just `index.tsx`)
20 | are setup to use `easy-peasy` via the `pages/_app.tsx`, which wraps all page components with the ``.
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/actions.md:
--------------------------------------------------------------------------------
1 | # Actions
2 |
3 | Allows you to get a type that represents the actions of a model.
4 |
5 | ```typescript
6 | Actions<
7 | Model extends Object = {}
8 | >
9 | ```
10 |
11 | ## Example
12 |
13 | ```typescript
14 | import { Actions } from 'easy-peasy';
15 | import { StoreModel } from './index';
16 |
17 | type StoreActions = Actions;
18 | ```
19 |
20 | Typically this would only be useful when using the `useStoreActions` hook.
21 |
22 | ```typescript
23 | import { useStoreActions, Actions } from 'easy-peasy';
24 | import { StoreModel } from './store';
25 |
26 | function MyComponent() {
27 | const addTodo = useStoreActions(
28 | (actions: Actions) => actions.todos.addTodo
29 | );
30 | }
31 | ```
32 |
33 | That being said, we recommend you use the [createTypedHooks](/docs/typescript-api/create-typed-hooks.html) API instead.
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/reducer.md:
--------------------------------------------------------------------------------
1 | # Reducer
2 |
3 | Defines a [reducer](/docs/api/reducer.html) against your model.
4 |
5 | ## API
6 |
7 | ```typescript
8 | Reducer<
9 | State = any,
10 | Action extends ReduxAction = ReduxAction
11 | >
12 | ```
13 |
14 | - `State`
15 |
16 | The type for the state that will be managed by the [reducer](/docs/api/reducer.html).
17 |
18 | - `Action`
19 |
20 | The type of the actions that may be received by the reducer.
21 |
22 |
23 | ## Example
24 |
25 | ```typescript
26 | import { Reducer, reducer } from 'easy-peasy';
27 |
28 | interface StoreModel {
29 | todos: Reducer;
30 | }
31 |
32 | const storeModel: StoreModel = {
33 | todos: reducer((state = [], action) => {
34 | switch (action.type) {
35 | case 'ADD_TODO': return [...state, action.payload];
36 | default: return state;
37 | }
38 | })
39 | }
40 | ```
--------------------------------------------------------------------------------
/src/create-transform.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This file has been copied from redux-persist.
3 | * The intention being to support as much of the redux-persist API as possible.
4 | */
5 |
6 | export function createTransform(inbound, outbound, config = {}) {
7 | const whitelist = config.whitelist || null;
8 | const blacklist = config.blacklist || null;
9 |
10 | function whitelistBlacklistCheck(key) {
11 | if (whitelist && whitelist.indexOf(key) === -1) return true;
12 | if (blacklist && blacklist.indexOf(key) !== -1) return true;
13 | return false;
14 | }
15 |
16 | return {
17 | in: (data, key, fullState) =>
18 | !whitelistBlacklistCheck(key) && inbound
19 | ? inbound(data, key, fullState)
20 | : data,
21 | out: (data, key, fullState) =>
22 | !whitelistBlacklistCheck(key) && outbound
23 | ? outbound(data, key, fullState)
24 | : data,
25 | };
26 | }
27 |
--------------------------------------------------------------------------------
/examples/reduxtagram/src/pages/root.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router-dom';
3 | import { StoreProvider } from 'easy-peasy';
4 | import store from '@/store';
5 | import PhotoGrid from '@/pages/photo-grid';
6 | import Single from '@/pages/single';
7 |
8 | export default function Root() {
9 | return (
10 | <>
11 |
12 |
13 |
14 | Reduxstagram
15 |
16 |
17 | }>
18 | } />
19 | } />
20 |
21 |
22 |
23 | >
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/examples/simple-todo/README.md:
--------------------------------------------------------------------------------
1 | # Simple todo example ([View codesandbox](https://codesandbox.io/s/fnidh1))
2 |
3 | The simplest example of `react` and `easy-peasy`.
4 |
5 | 
6 |
7 | This is a `Vite + React + Typescript + Eslint + Prettier` example based on [this template](https://github.com/TheSwordBreaker/vite-reactts-eslint-prettier).
8 |
9 | ## Getting Started
10 |
11 | First, run the development server:
12 |
13 | ```bash
14 | yarn dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `src/components/App.tsx`. The page auto-updates as you edit the file.
20 |
21 | The `easy-peasy` store & models are located in the `src/store` folder.
22 | The `main.tsx` file wraps the ` ` component with the ``, so all child components can access the
23 | hooks exposed from the `store/index.ts`.
24 |
--------------------------------------------------------------------------------
/examples/react-native-todo/README.md:
--------------------------------------------------------------------------------
1 | React Native Todo app based on React Native CLI quick start
2 |
3 | Store model copied (and modified) from [simple-todo](../simple-todo)
4 |
5 | 
6 |
7 | ## Getting Started
8 |
9 | > Ensure that you have a working [development environment](https://reactnative.dev/docs/environment-setup?guide=native).
10 |
11 | First, install the dependencies:
12 |
13 | ```bash
14 | yarn
15 | ```
16 |
17 | iOS
18 |
19 | ```bash
20 | yarn ios
21 | ```
22 |
23 | Android
24 |
25 | ```bash
26 | yarn android
27 | ```
28 |
29 | If metro does not start automatically, run:
30 |
31 | ```bash
32 | npx react-native start
33 | ```
34 |
35 | You can start editing the screen by modifying `src/components/TodoList.tsx`.
36 |
37 | The `easy-peasy` store & models are located under `src/store`.
38 | The `App.tsx` file wraps the ` ` component with the ``.
39 |
40 | Happy coding! 🍏
41 |
--------------------------------------------------------------------------------
/examples/reduxtagram/test/posts-model.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest';
2 | import { createStore } from 'easy-peasy';
3 | import { Post, postsModel } from '@/model';
4 |
5 | describe('Posts', () => {
6 | it('should be initialized', () => {
7 | // arrange
8 | const store = createStore(postsModel, { initialState: { posts: [] } });
9 |
10 | // assert
11 | expect(store.getState().posts).toEqual([]);
12 | });
13 |
14 | it('should like post', () => {
15 | // arrange
16 | const post: Post = {
17 | caption: 'Lunch #hamont',
18 | likes: 8,
19 | id: 'BAcyDyQwcXX',
20 | src: 'https://picsum.photos/400/400/?image=64',
21 | };
22 | const store = createStore(postsModel, {
23 | initialState: { posts: [post] },
24 | });
25 |
26 | // act
27 | store.getActions().likePost('BAcyDyQwcXX');
28 |
29 | // assert
30 | expect(store.getState().posts).toEqual([{ ...post, likes: 9 }]);
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/create-typed-hooks.md:
--------------------------------------------------------------------------------
1 | # createTypedHooks
2 |
3 | Creates typed versions of the hooks so that you don't need to apply typing information against them when using them within your components.
4 |
5 | ## Example
6 |
7 | ```typescript
8 | // hooks.js
9 | import { createTypedHooks } from 'easy-peasy';
10 | import { StoreModel } from './model';
11 |
12 | const { useStoreActions, useStoreState, useStoreDispatch, useStore } = createTypedHooks();
13 |
14 | export {
15 | useStoreActions,
16 | useStoreState,
17 | useStoreDispatch,
18 | useStore
19 | }
20 | ```
21 |
22 | And then use them within your components:
23 |
24 | ```typescript
25 | import { useStoreState } from './hooks'; // 👈 import the typed hooks
26 |
27 | export default MyComponent() {
28 | // This will be typed
29 | // 👇
30 | const message = useStoreState(state => state.message);
31 | return {message}
;
32 | }
33 | ```
34 |
--------------------------------------------------------------------------------
/examples/kanban/src/components/TaskList/TaskList.tsx:
--------------------------------------------------------------------------------
1 | import { useStoreState } from '../../store';
2 | import { StoreModel } from '../../store/model';
3 | import AddTask from './AddTask';
4 | import TaskView from './TaskView';
5 |
6 | const TaskList: React.FC<{ list: keyof StoreModel }> = ({ list }) => {
7 | const state = useStoreState((state) => state[list]);
8 |
9 | return (
10 |
11 |
12 | {state.name}
13 |
14 |
15 |
16 | {state.tasks.map((task) => (
17 |
18 | ))}
19 |
20 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default TaskList;
29 |
--------------------------------------------------------------------------------
/website/docs/docs/introduction/architecture.md:
--------------------------------------------------------------------------------
1 | # Architecture
2 |
3 | Easy Peasy is a full abstraction over Redux, providing an API that is both intuitive and quick to develop against, whilst removing any need for boilerplate. By wrapping Redux we get to leverage its mature architecture, whilst also being able to support the amazing tooling that has formed around it. For example, we support the [Redux Dev Tools Extension](https://github.com/zalmoxisus/redux-devtools-extension) out of the box.
4 |
5 | In addition to this, as we are outputing a Redux store, this allows interop with existing libraries and applications that are using React Redux. A big benefit of this is that you can apply a gradual migration of your existing applications from React Redux into Easy Peasy.
6 |
7 | To help support migration and interoperability we expose configuration allowing the extension of the underlying Redux store via middleware and enhancers.
8 |
9 | That being said, absolutely no Redux experience is required to use Easy Peasy.
10 |
--------------------------------------------------------------------------------
/tests/debug.test.js:
--------------------------------------------------------------------------------
1 | import { debug, action, createStore } from '../src';
2 | import { mockConsole } from './utils';
3 |
4 | let restore;
5 |
6 | beforeEach(() => {
7 | restore = mockConsole();
8 | });
9 |
10 | afterEach(() => {
11 | restore();
12 | });
13 |
14 | it('should return state with changes applied', () => {
15 | // ARRANGE
16 | const store = createStore({
17 | logs: ['foo'],
18 | add: action((state, payload) => {
19 | expect(debug(state)).toEqual({ logs: ['foo'] });
20 | state.logs.push(payload);
21 | expect(debug(state)).toEqual({ logs: ['foo', 'bar'] });
22 | }),
23 | });
24 |
25 | // ACT
26 | store.getActions().add('bar');
27 |
28 | // ASSERT
29 | expect(store.getState()).toEqual({ logs: ['foo', 'bar'] });
30 | });
31 |
32 | it('returns argument when not a draft', () => {
33 | // ARRANGE
34 | const notADraft = { foo: 'bar' };
35 |
36 | // ACT
37 | const actual = debug(notADraft);
38 |
39 | // ASSERT
40 | expect(actual).toBe(notADraft);
41 | });
42 |
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/action.md:
--------------------------------------------------------------------------------
1 | # Action
2 |
3 | Defines an [action](/docs/api/action.html) against your model
4 |
5 | ## API
6 |
7 | ```typescript
8 | Action<
9 | Model extends object = {},
10 | Payload = void
11 | >
12 | ```
13 |
14 | - `Model`
15 |
16 | The model against which the action is being defined. You need to provide this so that the state that will be provided to your [action](/docs/api/action.html) is correctly typed.
17 |
18 | - `Payload`
19 |
20 | The type of the payload that the [action](/docs/api/action.html) will receive. You can omit this if you do not expect the [action](/docs/api/action.html) to receive any payload.
21 |
22 |
23 | ## Example
24 |
25 | ```typescript
26 | import { Action, action } from 'easy-peasy';
27 |
28 | interface TodosModel {
29 | todos: string[];
30 | addTodo: Action;
31 | }
32 |
33 | const todosModel: TodosModel = {
34 | todos: [],
35 | addTodo: action((state, payload) => {
36 | state.todos.push(payload);
37 | })
38 | }
39 | ```
--------------------------------------------------------------------------------
/website/docs/docs/recipes/react-native-devtools.md:
--------------------------------------------------------------------------------
1 | # React Native Dev Tools
2 |
3 | React Native, hybrid, desktop and server side Redux apps can use Redux Dev Tools using the [Remote Redux DevTools](https://github.com/zalmoxisus/remote-redux-devtools) library.
4 |
5 | To use this library, you will need to pass the DevTools compose helper as part of the [config object](/docs/api/store-config.html) to `createStore`
6 |
7 | ```javascript
8 | import { createStore } from 'easy-peasy';
9 | import { composeWithDevTools } from 'remote-redux-devtools';
10 | import model from './model';
11 |
12 | const store = createStore(model, {
13 | compose: composeWithDevTools({ realtime: true, trace: true })
14 | });
15 |
16 | export default store;
17 | ```
18 |
19 | In the example above you will see that we are extending our store, providing an override to the default `compose` function used for our Redux store enhancers. We are utilising the compose exported by the [remote-redux-devtools](https://github.com/zalmoxisus/remote-redux-devtools) library.
20 |
--------------------------------------------------------------------------------
/website/docs/docs/introduction/README.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | Easy Peasy is an abstraction of Redux. It provides a reimagined API focussing on
4 | developer experience , allowing you to quickly
5 | and easily manage your state, whilst leveraging the strong
6 | architectural guarantees and providing integration with the
7 | extensive eco-system that Redux has to offer.
8 |
9 | Batteries are included - no configuration is required to
10 | support a robust and scalable state management
11 | solution that includes advanced features such as derived state, API calls,
12 | developer tools , and fully typed experience via
13 | TypeScript .
14 |
15 |
16 |
17 |
18 |
19 |
20 | Quick Start
21 |
22 |
--------------------------------------------------------------------------------
/examples/kanban/src/components/TaskList/AddTask.test.tsx:
--------------------------------------------------------------------------------
1 | import { createStore } from 'easy-peasy';
2 | import { describe, it, expect } from 'vitest';
3 |
4 | import model, { StoreModel } from '../../store/model';
5 | import { setup } from '../../utils/test-utils';
6 | import AddTask from './AddTask';
7 |
8 | const listKeys: Array = ['todo', 'doing', 'done'];
9 |
10 | describe.each(listKeys)(' ', (list) => {
11 | it('should add tasks correctly', async () => {
12 | const store = createStore(model);
13 | const { user, getByRole } = setup( , { store });
14 |
15 | await user.type(
16 | getByRole('textbox', { name: new RegExp(`task name for "${list}"`, 'i') }),
17 | 'My new task',
18 | );
19 | await user.click(
20 | getByRole('button', { name: new RegExp(`add task for "${list}"`, 'i') }),
21 | );
22 |
23 | const taskList = store.getState()[list].tasks;
24 | expect(taskList).toContainEqual({ id: expect.any(String), name: 'My new task' });
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/examples/reduxtagram/src/styles/_typography.css:
--------------------------------------------------------------------------------
1 | html {
2 | font-size: 10px;
3 | font-family: sans-serif;
4 | }
5 |
6 | p {
7 | font-size: 1.6rem;
8 | line-height: 1.5;
9 | }
10 |
11 | h1 {
12 | font-family: billabong, 'billabongregular', serif;
13 | text-align: center;
14 | font-weight: 100;
15 | font-size: 13rem;
16 | margin: 2rem 0;
17 | letter-spacing: -1px;
18 | text-shadow: 0 4px 0 rgba(18, 86, 136, 0.11);
19 | }
20 |
21 | h1 a {
22 | color: var(--blue);
23 | text-decoration: none;
24 | }
25 |
26 | h1 a:focus {
27 | outline: 0;
28 | }
29 |
30 | @font-face {
31 | font-family: 'billabongregular';
32 | src: url('../assets/fonts/billabong-webfont.eot');
33 | src: url('../assets/fonts/billabong-webfont.eot?#iefix') format('embedded-opentype'),
34 | url('../assets/fonts/billabong-webfont.woff') format('woff'),
35 | url('../assets/fonts/billabong-webfont.ttf') format('truetype'),
36 | url('../assets/fonts/billabong-webfont.svg#billabongregular') format('svg');
37 | font-weight: normal;
38 | font-style: normal;
39 | }
40 |
--------------------------------------------------------------------------------
/tests/typescript/complex-store-inheritance.ts:
--------------------------------------------------------------------------------
1 | import { Thunk } from 'easy-peasy';
2 |
3 | type Injections = any;
4 |
5 | type ISomeOtherModel = {};
6 |
7 | interface IModelA {
8 | commitSomething: Thunk<
9 | IModelAStoreModel,
10 | void,
11 | Injections,
12 | IModelAStoreModel
13 | >;
14 | }
15 |
16 | // IModelAStoreModel is generic, and requires the root store as input
17 | type IModelAStoreModel = IModelA &
18 | ISomeOtherModel & {
19 | onSomethingLoaded: Thunk;
20 | };
21 |
22 | // IModelBStoreModel is not generic, and is based on IModelAStoreModel, but it does not care about the generic type
23 | // of IModelAStoreModel (thus it is just passing`any` to`IModelAStoreModel`)
24 | interface IModelBStoreModel extends IModelAStoreModel {}
25 |
26 | // IStoreModel should be allowed to extend both IModelAStoreModel & IModelBStoreModel
27 | interface IStoreModel
28 | extends IModelAStoreModel,
29 | IModelBStoreModel {}
30 |
--------------------------------------------------------------------------------
/examples/kanban/src/components/TaskList/TaskList.test.tsx:
--------------------------------------------------------------------------------
1 | import { createStore } from 'easy-peasy';
2 | import { describe, it, expect } from 'vitest';
3 |
4 | import model, { StoreModel } from '../../store/model';
5 | import { setup } from '../../utils/test-utils';
6 | import TaskList from './TaskList';
7 |
8 | const listKeys: Array = ['todo', 'doing', 'done'];
9 |
10 | describe.each(listKeys)(' ', (list) => {
11 | it('should render the tasks correctly', () => {
12 | const store = createPopulatedStore();
13 | const { container } = setup( , { store });
14 |
15 | expect(container).toMatchSnapshot();
16 | });
17 | });
18 |
19 | const createPopulatedStore = () => {
20 | const store = createStore(model);
21 | for (let i = 0; i < 3; i++) {
22 | store.getActions().todo.addTask({ id: `todo-${i}`, name: `Todo ${i}` });
23 | store.getActions().doing.addTask({ id: `doing-${i}`, name: `Doing ${i}` });
24 | store.getActions().done.addTask({ id: `done-${i}`, name: `Done ${i}` });
25 | }
26 | return store;
27 | };
28 |
--------------------------------------------------------------------------------
/website/docs/docs/community-extensions/README.md:
--------------------------------------------------------------------------------
1 | # Community Extensions
2 |
3 | Below is a list of some of the work performed by the community, providing some interesting extensions to Easy Peasy.
4 |
5 | - [`easy-peasy-decorators`](#easy-peasy-decoratorshttpsgithubcomeasypeasy-communitydecorators)
6 |
7 | ## [`easy-peasy-decorators`](https://github.com/easypeasy-community/decorators)
8 |
9 | This is a lightweight TypeScript library, providing the ability to generate stores via classes and decorators.
10 |
11 | ```typescript
12 | import { Model, Property, Action, createStore } from 'easy-peasy-decorators';
13 |
14 | @Model('todos')
15 | class TodoModel {
16 | @Property()
17 | public items = ['Create store', 'Wrap application', 'Use store'];
18 |
19 | @Action()
20 | add(payload: string) {
21 | this.items.push(payload);
22 | }
23 | }
24 |
25 | interface IStoreModel {
26 | todos: TodoModel;
27 | }
28 |
29 | export const store = createStore();
30 | ```
31 |
32 | Check out the [GitHub repository](https://github.com/easypeasy-community/decorators) for more information.
33 |
--------------------------------------------------------------------------------
/examples/reduxtagram/src/pages/single.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useParams } from 'react-router';
3 | import AddCommentForm from '@/components/add-comment-form';
4 | import Photo from '@/components/photo';
5 | import PostComment from '@/components/post-comment';
6 | import { useStoreState } from '@/hooks';
7 |
8 | export default function Single() {
9 | const { postId } = useParams<{ postId: string }>();
10 | const post = useStoreState((state) => state.postsModel.posts.find((item) => item.id === postId));
11 | const postComments = useStoreState((state) => (post ? state.commentsModel.byPostId(post.id) : []));
12 |
13 | if (post) {
14 | return (
15 |
16 |
17 |
18 | {postComments.map((comment) => (
19 |
20 | ))}
21 |
22 |
23 |
24 | );
25 | } else {
26 | return null;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/website/docs/docs/known-issues/using-keyof-in-generic-typescript-model.md:
--------------------------------------------------------------------------------
1 | # Using `keyof T` within a generic TypeScript model
2 |
3 | When defining generic model helpers via TypeScript you will be unable to put a restriction within your generic model based on the `keyof` the incoming generic model argument. This is illustrated below.
4 |
5 | ```typescript
6 | import { computed, Computed, action, Action, thunk, Thunk } from "easy-peasy";
7 |
8 | export interface DataModel- {
9 | items: Array
- ;
10 | count: Computed
, number>;
11 | // This is not supported. It currently breaks the Easy Peasy typings,
12 | // resulting in the `dataModel` helper below not presenting the correct type
13 | // information to you.
14 | // 👇
15 | sortBy: keyof Item | "none";
16 | }
17 |
18 | export const dataModel = - (items: Item[]): DataModel
- => ({
19 | items: items,
20 | // This typing information would be invalid
21 | // 👇
22 | count: computed(state => state.items.length),
23 | sortBy: "none"
24 | });
25 | ```
--------------------------------------------------------------------------------
/examples/nextjs-todo/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
--------------------------------------------------------------------------------
/examples/react-native-todo/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/website/docs/docs/api/use-store-actions.md:
--------------------------------------------------------------------------------
1 | # useStoreActions
2 |
3 | A [hook](https://reactjs.org/docs/hooks-intro.html) granting your components access to the [store's](/docs/api/store.html) [actions](/docs/api/action.html).
4 |
5 | ```javascript
6 | const addTodo = useStoreActions(actions => actions.todos.add);
7 | ```
8 |
9 | ## Arguments
10 |
11 | - `mapActions` (Function, required)
12 |
13 | The function that is used to resolve the [action](/docs/api/action.html) that your component requires. It receives the following arguments:
14 |
15 | - `actions` (Object)
16 |
17 | The [actions](/docs/api/action.html) of your store.
18 |
19 | ## Example
20 |
21 | ```javascript
22 | import { useState } from 'react';
23 | import { useStoreActions } from 'easy-peasy';
24 |
25 | const AddTodo = () => {
26 | const [text, setText] = useState('');
27 | const addTodo = useStoreActions(actions => actions.todos.add);
28 | return (
29 |
30 | setText(e.target.value)} />
31 | addTodo(text)}>Add
32 |
33 | );
34 | };
35 | ```
36 |
--------------------------------------------------------------------------------
/examples/react-native-todo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ReactNativeTodo",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "android": "react-native run-android",
7 | "ios": "react-native run-ios",
8 | "lint": "eslint .",
9 | "start": "react-native start",
10 | "test": "jest"
11 | },
12 | "dependencies": {
13 | "easy-peasy": "^6.0.0",
14 | "react": "18.2.0",
15 | "react-native": "0.71.4"
16 | },
17 | "devDependencies": {
18 | "@babel/core": "^7.20.0",
19 | "@babel/preset-env": "^7.20.0",
20 | "@babel/runtime": "^7.20.0",
21 | "@react-native-community/eslint-config": "^3.2.0",
22 | "@tsconfig/react-native": "^2.0.2",
23 | "@types/jest": "^29.2.1",
24 | "@types/react": "^18.0.24",
25 | "@types/react-test-renderer": "^18.0.0",
26 | "babel-jest": "^29.2.1",
27 | "eslint": "^8.19.0",
28 | "jest": "^29.2.1",
29 | "metro-react-native-babel-preset": "0.73.8",
30 | "prettier": "^2.4.1",
31 | "react-test-renderer": "18.2.0",
32 | "typescript": "4.8.4"
33 | },
34 | "jest": {
35 | "preset": "react-native"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/website/docs/docs/known-issues/typescript-optional-computed-properties.md:
--------------------------------------------------------------------------------
1 | # Marking computed properties as optional on your TypeScript model
2 |
3 | Unfortunately, due to the way our typing system maps your model, you cannot
4 | declare a computed property as being optional via the `?` property postfix.
5 |
6 | For example:
7 |
8 | ```typescript
9 | interface StoreModel {
10 | products: Product[];
11 | totalPrice?: Computed;
12 | // 👆
13 | // Note the optional definition
14 | }
15 |
16 | const storeModel: StoreModel = {
17 | products: [];
18 | // This will result in a TypeScript error 😢
19 | totalPrice: computed(
20 | state => state.products.length > 0
21 | ? calcPrice(state.products)
22 | : undefined
23 | )
24 | }
25 | ```
26 |
27 | Luckily there is a workaround; simply adjust the definition of your computed
28 | property to indicate that the result could be undefined.
29 |
30 | ```diff
31 | interface StoreModel {
32 | products: Product[];
33 | - totalPrice?: Computed;
34 | + totalPrice: Computed;
35 | }
36 | ```
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018-present Sean Matheson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/examples/reduxtagram/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "es2021": true,
6 | "node": true
7 | },
8 | "overrides": [
9 | {
10 | "files": [
11 | "*.js"
12 | ],
13 | "extends": [
14 | "eslint:recommended",
15 | "plugin:prettier/recommended"
16 | ]
17 | },
18 | {
19 | "files": [
20 | "*.ts",
21 | "*.tsx"
22 | ],
23 | "settings": {
24 | "react": {
25 | "version": "detect"
26 | }
27 | },
28 | "parser": "@typescript-eslint/parser",
29 | "parserOptions": {
30 | "ecmaFeatures": {
31 | "jsx": true
32 | },
33 | "ecmaVersion": "latest",
34 | "sourceType": "module"
35 | },
36 | "extends": [
37 | "eslint:recommended",
38 | "plugin:@typescript-eslint/recommended",
39 | "plugin:react/recommended",
40 | "plugin:prettier/recommended"
41 | ],
42 | "rules": {
43 | "react/react-in-jsx-scope": "off",
44 | "@typescript-eslint/no-non-null-assertion": "off"
45 | }
46 | }
47 | ]
48 | }
49 |
--------------------------------------------------------------------------------
/examples/react-native-todo/ios/ReactNativeTodo/Images.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ios-marketing",
45 | "scale" : "1x",
46 | "size" : "1024x1024"
47 | }
48 | ],
49 | "info" : {
50 | "author" : "xcode",
51 | "version" : 1
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/tests/typescript/issue427.ts:
--------------------------------------------------------------------------------
1 | import { Computed, computed } from 'easy-peasy';
2 |
3 | interface AppModel {
4 | currentRoleId?: number;
5 | }
6 |
7 | interface Role {
8 | id: number;
9 | name: string;
10 | description: string;
11 | }
12 |
13 | interface RolesModel {
14 | rolesMap: { [roleId: string]: Role };
15 | currentRole: Computed;
16 | currentRoleName: Computed;
17 | }
18 |
19 | interface StoreModel {
20 | app: AppModel;
21 | roles: RolesModel;
22 | }
23 |
24 | const storeModel: StoreModel = {
25 | app: {
26 | currentRoleId: 1,
27 | },
28 | roles: {
29 | rolesMap: {
30 | '1': { id: 1, name: 'Role example', description: 'Role description' },
31 | },
32 | currentRole: computed(
33 | [
34 | (_, storeState) => storeState.app.currentRoleId,
35 | (state) => state.rolesMap,
36 | ],
37 | (roleId, rolesMap) => (roleId != null ? rolesMap[roleId] : undefined),
38 | ),
39 | currentRoleName: computed(
40 | [(state) => state.currentRole],
41 | (role) => role && role.name,
42 | ),
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/tests/typescript/use-local-store.tsx:
--------------------------------------------------------------------------------
1 | import { useLocalStore, Action, action } from 'easy-peasy';
2 |
3 | interface StoreModel {
4 | count: number;
5 | inc: Action;
6 | }
7 |
8 | const [state, actions, store] = useLocalStore(() => ({
9 | count: 0,
10 | inc: action((state) => {
11 | state.count += 1;
12 | }),
13 | }));
14 |
15 | state.count + 1;
16 | actions.inc();
17 | store.getState().count + 1;
18 |
19 | useLocalStore(
20 | () => ({
21 | count: 0,
22 | inc: action((state) => {
23 | state.count += 1;
24 | }),
25 | }),
26 | ['foo', 123],
27 | );
28 |
29 | useLocalStore(
30 | (prevState) => {
31 | if (prevState != null) {
32 | prevState.count + 1;
33 | }
34 | return {
35 | count: 0,
36 | inc: action((state) => {
37 | state.count += 1;
38 | }),
39 | };
40 | },
41 | ['foo', 123],
42 | (prevState, prevConfig) => {
43 | if (prevState != null) {
44 | prevState.count + 1;
45 | }
46 | if (prevConfig != null) {
47 | `${prevConfig.name}foo`;
48 | }
49 | return {
50 | name: 'MyLocalStore',
51 | };
52 | },
53 | );
54 |
--------------------------------------------------------------------------------
/examples/reduxtagram/src/components/add-comment-form.tsx:
--------------------------------------------------------------------------------
1 | import React, { FormEvent, useState } from 'react';
2 | import { useStoreActions } from '@/hooks';
3 |
4 | interface Props {
5 | postId: string;
6 | }
7 |
8 | export default function AddCommentForm({ postId }: Props) {
9 | const addComment = useStoreActions((actions) => actions.commentsModel.addComment);
10 | const [author, setAuthor] = useState('');
11 | const [comment, setComment] = useState('');
12 |
13 | const onSubmit = (e: FormEvent) => {
14 | e.preventDefault();
15 |
16 | if (author && comment) {
17 | addComment({ postId, user: author, text: comment });
18 | setAuthor('');
19 | setComment('');
20 | }
21 | };
22 |
23 | return (
24 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/website/docs/docs/api/debug.md:
--------------------------------------------------------------------------------
1 | # debug
2 |
3 | This helper is useful in the context of [actions](/docs/api/action.html).
4 |
5 | [Actions](/docs/api/action.html) use the [immer](https://github.com/mweststrate/immer) library under the hood in order to convert mutative updates into immutable ones. Therefore if you try to `console.log` your state within an [action](/doc/api/action.html) you will see a `Proxy` object or a `null` is printed.
6 |
7 | Use this helper in order to get the actual value of the `state` within your [action](/docs/api/action.html).
8 |
9 | _Before:_
10 |
11 | ```javascript
12 | const model = {
13 | increment: action((state, payload) => {
14 | state.count += 1;
15 | console.log(state); // 👈 prints a Proxy object or a null
16 | })
17 | }
18 | ```
19 |
20 | _After:_
21 |
22 | ```javascript
23 | import { debug } from 'easy-peasy';
24 |
25 | const model = {
26 | increment: action((state, payload) => {
27 | state.count += 1;
28 | console.log(debug(state)); // 👈 prints the "native" state representation
29 | })
30 | }
31 | ```
32 |
33 | > ***Note:*** *If you have set the `disableImmer` configuration value on the store you will not need to use this helper.*
--------------------------------------------------------------------------------
/website/docs/docs/recipes/connecting-to-reactotron.md:
--------------------------------------------------------------------------------
1 | # Connecting to Reactotron
2 |
3 | [Reactotron](https://github.com/infinitered/reactotron) is a desktop app for
4 | inspecting your React JS and React Native projects.
5 |
6 | It is possible to configure Easy Peasy so to be connected to your Reactotron
7 | instance.
8 |
9 | Firstly, ensure you have a Reactotron configuration similar to.
10 |
11 | ```javascript
12 | // reactotron.js
13 |
14 | import Reactotron from 'reactotron-react-native';
15 | import { reactotronRedux } from 'reactotron-redux';
16 |
17 | const reactron = Reactotron.configure()
18 | .useReactNative()
19 | .use(reactotronRedux())
20 | .connect();
21 |
22 | export default reactron;
23 | ```
24 |
25 | Then update the manner in which you create your Easy Peasy store.
26 |
27 | ```javascript
28 | // create-store.js
29 |
30 | import { createStore } from 'easy-peasy';
31 | import model from './model';
32 |
33 | let storeEnhancers = [];
34 |
35 | if (__DEV__) {
36 | const reactotron = require('./reactotron').default;
37 | storeEnhancers = [...storeEnhancers, reactotron.createEnhancer()];
38 | }
39 |
40 | const store = createStore(model, {
41 | enhancers: [...storeEnhancers],
42 | });
43 |
44 | export default store;
45 | ```
46 |
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/action-on.md:
--------------------------------------------------------------------------------
1 | # ActionOn
2 |
3 | Defines an [actionOn](/docs/api/action-on.html) listener against your model.
4 |
5 | ## API
6 |
7 | ```typescript
8 | ActionOn<
9 | Model extends object = {},
10 | StoreModel extends object = {}
11 | >
12 | ```
13 |
14 | - `Model`
15 |
16 | The model against which the [actionOn](/docs/api/action-on.html) is being defined. You need to provide this so that the state that will be provided to your [actionOn](/docs/api/action-on.html) is correctly typed.
17 |
18 | - `StoreModel`
19 |
20 | If you plan on targeting an action from another part of your store state then you will need to provide your store model so that the provided store actions are correctly typed.
21 |
22 |
23 | ## Example
24 |
25 | ```typescript
26 | import { ActionOn, actionOn } from 'easy-peasy';
27 | import { StoreModel } from '../index';
28 |
29 | interface AuditModel {
30 | logs: string[];
31 | onTodoAdded: ActionOn;
32 | }
33 |
34 | const auditModel: AuditModel = {
35 | logs: [],
36 | onTodoAdded: actionOn(
37 | (actions, storeActions) => storeActions.todos.addTodo,
38 | (state, payload) => {
39 | state.logs.push(`Added todo: ${payload}`);
40 | }
41 | )
42 | }
43 | ```
--------------------------------------------------------------------------------
/tests/typescript/issue246.ts:
--------------------------------------------------------------------------------
1 | import { Action, action, createStore } from 'easy-peasy';
2 |
3 | interface Item {
4 | id: number;
5 | name: string;
6 | }
7 |
8 | interface BeholderModel extends BaseListModel {
9 | // Extra stuff for interface
10 | beholderName: string;
11 | }
12 |
13 | interface CreatorModel extends BaseListModel {
14 | // Extra stuff for interface
15 | creatorName: string;
16 | }
17 |
18 | // error at in setList
19 | interface BaseListModel {
20 | list: Item[];
21 | setList: Action;
22 | }
23 |
24 | interface StoreModel {
25 | beholder: BeholderModel;
26 | creator: CreatorModel;
27 | }
28 |
29 | const model: StoreModel = {
30 | beholder: {
31 | beholderName: 'foo',
32 | list: [],
33 | setList: action((state, payload) => {
34 | state.list = payload;
35 | }),
36 | },
37 | creator: {
38 | creatorName: 'foo',
39 | list: [],
40 | setList: action((state, payload) => {
41 | state.list = payload;
42 | }),
43 | },
44 | };
45 |
46 | const store = createStore(model);
47 |
48 | store.getState().beholder.beholderName;
49 | store.getState().beholder.list;
50 | store.getActions().creator.setList([{ id: 1, name: 'foo' }]);
51 |
--------------------------------------------------------------------------------
/tests/immer.test.js:
--------------------------------------------------------------------------------
1 | import './lib/enable-immer-map-set';
2 | import React, { act } from 'react';
3 | import { render } from '@testing-library/react';
4 | import { createStore, action, StoreProvider, useStoreState } from '../src';
5 |
6 | test('Map and Set within a store work as expected', () => {
7 | // ARRANGE
8 | const store = createStore({
9 | products: new Set(),
10 | addProduct: action((state, payload) => {
11 | state.products.add(payload);
12 | }),
13 | });
14 |
15 | function App() {
16 | const products = useStoreState((state) => state.products);
17 | const productsArray = [...products];
18 | return (
19 |
20 | {productsArray.length === 0 ? 'none' : productsArray.join(',')}
21 |
22 | );
23 | }
24 |
25 | const { getByTestId } = render(
26 |
27 |
28 | ,
29 | );
30 |
31 | // ASSERT
32 | expect(getByTestId('products').textContent).toBe('none');
33 |
34 | // ACT
35 | act(() => {
36 | store.getActions().addProduct('potato');
37 | store.getActions().addProduct('avocado');
38 | });
39 |
40 | // ASSERT
41 | expect(getByTestId('products').textContent).toBe('potato,avocado');
42 | });
43 |
--------------------------------------------------------------------------------
/examples/react-native-todo/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | #
3 | .DS_Store
4 |
5 | # Xcode
6 | #
7 | build/
8 | *.pbxuser
9 | !default.pbxuser
10 | *.mode1v3
11 | !default.mode1v3
12 | *.mode2v3
13 | !default.mode2v3
14 | *.perspectivev3
15 | !default.perspectivev3
16 | xcuserdata
17 | *.xccheckout
18 | *.moved-aside
19 | DerivedData
20 | *.hmap
21 | *.ipa
22 | *.xcuserstate
23 | ios/.xcode.env.local
24 |
25 | # Android/IntelliJ
26 | #
27 | build/
28 | .idea
29 | .gradle
30 | local.properties
31 | *.iml
32 | *.hprof
33 | .cxx/
34 | *.keystore
35 | !debug.keystore
36 |
37 | # node.js
38 | #
39 | node_modules/
40 | npm-debug.log
41 | yarn-error.log
42 |
43 | # fastlane
44 | #
45 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
46 | # screenshots whenever they are needed.
47 | # For more information about the recommended setup visit:
48 | # https://docs.fastlane.tools/best-practices/source-control/
49 |
50 | **/fastlane/report.xml
51 | **/fastlane/Preview.html
52 | **/fastlane/screenshots
53 | **/fastlane/test_output
54 |
55 | # Bundle artifact
56 | *.jsbundle
57 |
58 | # Ruby / CocoaPods
59 | /ios/Pods/
60 | /vendor/bundle/
61 |
62 | # Temporary files created by Metro to check the health of the file watcher
63 | .metro-health-check*
64 |
--------------------------------------------------------------------------------
/website/docs/docs/recipes/hot-reloading.md:
--------------------------------------------------------------------------------
1 | # Hot Reloading
2 |
3 | Easy Peasy supports hot reloading - i.e. being able to dynamically update your model at development time whilst maintaining the current state of your application. This can lead to a much improved developer experience.
4 |
5 | In order to configure your application to allow hot reloading of your Easy Peasy store you will need to do the following:
6 |
7 | ```javascript
8 | // src/store/index.js
9 |
10 | import { createStore } from "easy-peasy";
11 | import model from "./model";
12 |
13 | const store = createStore(model);
14 |
15 | // Wrapping dev only code like this normally gets stripped out by bundlers
16 | // such as Webpack when creating a production build.
17 | if (process.env.NODE_ENV === "development") {
18 | if (module.hot) {
19 | module.hot.accept("./model", () => {
20 | store.reconfigure(model); // 👈 Here is the magic
21 | });
22 | }
23 | }
24 |
25 | export default store;
26 | ```
27 |
28 | Note how you can call the [store's](/docs/api/store.html) `reconfigure` method in order to reconfigure the store with your updated model. The existing state will be maintained.
29 |
30 | You can [view a demo repository configured for hot reloading here](https://github.com/ctrlplusb/easy-peasy-hot-reload).
--------------------------------------------------------------------------------
/src/use-local-store.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 | import { useMemoOne } from './lib';
3 | import { createStore } from './create-store';
4 |
5 | export function useLocalStore(
6 | modelCreator,
7 | dependencies = [],
8 | configCreator = null,
9 | ) {
10 | const storeRef = useRef();
11 |
12 | const configRef = useRef();
13 |
14 | const store = useMemoOne(() => {
15 | const previousState =
16 | storeRef.current != null ? storeRef.current.getState() : undefined;
17 | const config =
18 | configCreator != null
19 | ? configCreator(previousState, configRef.current)
20 | : undefined;
21 | const _store = createStore(modelCreator(previousState), config);
22 | configRef.current = config;
23 | storeRef.current = _store;
24 | return _store;
25 | }, dependencies);
26 |
27 | const [currentState, setCurrentState] = useState(() => store.getState());
28 |
29 | useEffect(() => {
30 | setCurrentState(store.getState());
31 | return store.subscribe(() => {
32 | const nextState = store.getState();
33 | if (currentState !== nextState) {
34 | setCurrentState(nextState);
35 | }
36 | });
37 | }, [store]);
38 |
39 | return [currentState, store.getActions(), store];
40 | }
41 |
--------------------------------------------------------------------------------
/tests/testing/listeners.test.js:
--------------------------------------------------------------------------------
1 | import { action, createStore, actionOn } from '../../src';
2 |
3 | const model = {
4 | todos: [],
5 | logs: [],
6 | addTodo: action((state, payload) => {
7 | state.todos.push(payload);
8 | }),
9 | onTodoAdded: actionOn(
10 | (actions) => actions.addTodo,
11 | (state, target) => {
12 | state.logs.push(`Added todo: ${target.payload}`);
13 | },
14 | ),
15 | };
16 |
17 | test('listener gets dispatched when target fires', () => {
18 | // ARRANGE
19 | const store = createStore(model, {
20 | mockActions: true,
21 | });
22 |
23 | // ACT
24 | store.getActions().addTodo('Write docs');
25 |
26 | // ASSERT
27 | expect(store.getMockedActions()).toMatchObject([
28 | { type: '@action.addTodo', payload: 'Write docs' },
29 | {
30 | type: '@actionOn.onTodoAdded',
31 | payload: {
32 | type: '@action.addTodo',
33 | payload: 'Write docs',
34 | },
35 | },
36 | ]);
37 | });
38 |
39 | test('listener acts as expected', () => {
40 | // ARRANGE
41 | const store = createStore(model);
42 |
43 | // ACT
44 | store.getListeners().onTodoAdded({
45 | type: '@action.addTodo',
46 | payload: 'Test listeners',
47 | });
48 |
49 | // ASSERT
50 | expect(store.getState().logs).toEqual(['Added todo: Test listeners']);
51 | });
52 |
--------------------------------------------------------------------------------
/examples/react-native-todo/ios/ReactNativeTodo/AppDelegate.mm:
--------------------------------------------------------------------------------
1 | #import "AppDelegate.h"
2 |
3 | #import
4 |
5 | @implementation AppDelegate
6 |
7 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
8 | {
9 | self.moduleName = @"ReactNativeTodo";
10 | // You can add your custom initial props in the dictionary below.
11 | // They will be passed down to the ViewController used by React Native.
12 | self.initialProps = @{};
13 |
14 | return [super application:application didFinishLaunchingWithOptions:launchOptions];
15 | }
16 |
17 | - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
18 | {
19 | #if DEBUG
20 | return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
21 | #else
22 | return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
23 | #endif
24 | }
25 |
26 | /// This method controls whether the `concurrentRoot`feature of React18 is turned on or off.
27 | ///
28 | /// @see: https://reactjs.org/blog/2022/03/29/react-v18.html
29 | /// @note: This requires to be rendering on Fabric (i.e. on the New Architecture).
30 | /// @return: `true` if the `concurrentRoot` feature is enabled. Otherwise, it returns `false`.
31 | - (BOOL)concurrentRootEnabled
32 | {
33 | return true;
34 | }
35 |
36 | @end
37 |
--------------------------------------------------------------------------------
/examples/react-native-todo/src/components/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | import React, {PropsWithChildren} from 'react';
2 | import {View, Switch, Text, StyleSheet} from 'react-native';
3 | import {useStoreState} from '../store';
4 |
5 | type Props = PropsWithChildren<{
6 | isEnabled: boolean;
7 | setIsEnabled: Function;
8 | }>;
9 |
10 | const Toolbar = ({isEnabled, setIsEnabled}: Props): JSX.Element => {
11 | const {completedCount, totalCount} = useStoreState(state => state);
12 |
13 | const toggleSwitch = () => {
14 | setIsEnabled((previousState: Boolean) => !previousState);
15 | };
16 |
17 | return (
18 |
19 |
20 | {completedCount} of {totalCount}
21 |
22 |
23 | Hide Done
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | const styles = StyleSheet.create({
31 | row: {
32 | flexDirection: 'row',
33 | justifyContent: 'space-between',
34 | alignItems: 'center',
35 | paddingVertical: 10,
36 | },
37 | switchLabel: {
38 | alignItems: 'center',
39 | flexDirection: 'row',
40 | },
41 | label: {
42 | marginHorizontal: 5,
43 | fontSize: 16,
44 | },
45 | });
46 |
47 | export default Toolbar;
48 |
--------------------------------------------------------------------------------
/examples/kanban/src/components/TaskList/AddTask.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useStoreActions, useStoreState } from '../../store';
3 | import { StoreModel } from '../../store/model';
4 | import generateId from '../../utils/generateId';
5 |
6 | const AddTask: React.FC<{ list: keyof StoreModel }> = ({ list }) => {
7 | const [name, setName] = useState('');
8 | const { name: listName } = useStoreState((state) => state[list]);
9 | const { addTask } = useStoreActions((actions) => actions[list]);
10 |
11 | return (
12 |
35 | );
36 | };
37 |
38 | export default AddTask;
39 |
--------------------------------------------------------------------------------
/examples/simple-todo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "easy-peasy-example-simple-todo",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "dev": "vite --port 3000",
6 | "build": "tsc && vite build",
7 | "serve": "vite preview",
8 | "lint:fix": "eslint ./src --ext .jsx,.js,.ts,.tsx --quiet --fix --ignore-path ./.gitignore",
9 | "lint:format": "prettier --loglevel warn --write \"./**/*.{js,jsx,ts,tsx,css,md,json}\" ",
10 | "lint": "yarn lint:format && yarn lint:fix ",
11 | "type-check": "tsc"
12 | },
13 | "dependencies": {
14 | "easy-peasy": "^6.0.0",
15 | "react": "^19.0.0",
16 | "react-dom": "^19.0.0"
17 | },
18 | "devDependencies": {
19 | "@types/react": "^19.0.8",
20 | "@types/react-dom": "^19.0.3",
21 | "@typescript-eslint/eslint-plugin": "^5.10.2",
22 | "@typescript-eslint/parser": "^5.10.2",
23 | "@vitejs/plugin-react": "^1.3.2",
24 | "eslint": "^8.8.0",
25 | "eslint-config-prettier": "^8.3.0",
26 | "eslint-plugin-import": "^2.25.4",
27 | "eslint-plugin-jsx-a11y": "^6.5.1",
28 | "eslint-plugin-prettier": "^4.0.0",
29 | "eslint-plugin-react": "^7.28.0",
30 | "eslint-plugin-simple-import-sort": "^7.0.0",
31 | "pre-commit": "^1.2.2",
32 | "prettier": "^2.5.1",
33 | "typescript": "^4.5.5",
34 | "vite": "^3.1.1"
35 | },
36 | "pre-commit": "lint",
37 | "license": "MIT"
38 | }
39 |
--------------------------------------------------------------------------------
/examples/kanban/src/components/TaskList/TaskView.test.tsx:
--------------------------------------------------------------------------------
1 | import { createStore } from 'easy-peasy';
2 | import { describe, it, expect } from 'vitest';
3 |
4 | import model, { StoreModel } from '../../store/model';
5 | import { setup } from '../../utils/test-utils';
6 | import TaskView from './TaskView';
7 |
8 | const listKeys: Array = ['todo', 'doing', 'done'];
9 |
10 | describe.each(listKeys)(' ', (list) => {
11 | it('should remove the task when clicking the remove button', async () => {
12 | const store = createPopulatedStore();
13 | const [firstTask] = store.getState()[list].tasks;
14 |
15 | const { user, getByRole } = setup( , {
16 | store,
17 | });
18 |
19 | await user.click(
20 | getByRole('button', { name: new RegExp(`remove "${firstTask.name}"`, 'i') }),
21 | );
22 |
23 | expect(store.getState()[list].tasks).not.toContain(firstTask);
24 | });
25 | });
26 |
27 | const createPopulatedStore = () => {
28 | const store = createStore(model);
29 | for (let i = 0; i < 3; i++) {
30 | store.getActions().todo.addTask({ id: `todo-${i}`, name: `Todo ${i}` });
31 | store.getActions().doing.addTask({ id: `doing-${i}`, name: `Doing ${i}` });
32 | store.getActions().done.addTask({ id: `done-${i}`, name: `Done ${i}` });
33 | }
34 | return store;
35 | };
36 |
--------------------------------------------------------------------------------
/examples/reduxtagram/README.md:
--------------------------------------------------------------------------------
1 | # Reduxtagram ([View sandbox](https://codesandbox.io/s/ztuxzk))
2 |
3 | A simple React + [Easy Peasy](https://easy-peasy.now.sh/) (Redux) implementation inspired from [Learn Redux course](https://learnredux.com/).
4 |
5 | This project uses [Vite](https://vitejs.dev) and [Vitest](https://vitest.dev) to run, build and test the application.
6 |
7 | ## Getting started
8 |
9 | In the project directory, you can run:
10 |
11 | ### Run the application
12 |
13 | ```shell
14 | yarn dev
15 | ```
16 |
17 | Runs the app in the development mode.
18 | Open [http://localhost:3000/](http://localhost:3000/) to view it in the browser.
19 |
20 | The page will reload if you make edits.
21 |
22 | ### Run the tests
23 |
24 | ```shell
25 | # run tests in interactive watch mode in the console
26 | yarn test
27 |
28 | # run tests in interactive watch mode and viewing the results in a browser - uses Vitest UI
29 | yarn test --ui
30 |
31 | # run tests with coverage and exit
32 | yarn test --coverage --run
33 | ```
34 |
35 | ### Build and preview the application
36 |
37 | ```shell
38 | # Builds the app for production to the `build` folder.
39 | yarn build
40 |
41 | # Preview the build
42 | yarn run preview
43 | ```
44 |
45 | ## Learn More
46 |
47 | To learn more about [Easy Peasy](https://easy-peasy.dev), check out the [documentation](https://easy-peasy.dev/docs/introduction/).
48 |
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/effect-on.md:
--------------------------------------------------------------------------------
1 | # EffectOn
2 |
3 | ## API
4 |
5 | ```typescript
6 | type EffectOn<
7 | Model extends object = {},
8 | StoreModel extends object = {},
9 | Injections = any
10 | >
11 | ```
12 |
13 | - `Model`
14 |
15 | The model against which the [effectOn](/docs/api/effect-on.html) property is
16 | being defined. You need to provide this so that the state that will be
17 | provided to your [effectOn](/docs/api/effect-on.html) is correctly typed.
18 |
19 | - `StoreModel`
20 |
21 | If you expect to target state from the entire store then you will need to
22 | provide your store's model interface so that the store state is correctly
23 | typed.
24 |
25 | - `Injections`
26 |
27 | If your store was configured with injections, and you intend to use them
28 | within your [effectOn](/docs/api/effect-on.html), then you should provide the
29 | type of the injections here.
30 |
31 | ## Example
32 |
33 | ```typescript
34 | import { EffectOn, effectOn } from 'easy-peasy';
35 |
36 | interface TodosModel {
37 | todos: string[];
38 | onTodosChanged: EffectOn;
39 | }
40 |
41 | const todosModel: TodosModel = {
42 | todos: [],
43 | onTodosChanged: effectOn(
44 | [(state) => state.todos],
45 | async (actions, change) => {
46 | const [todos] = change.current;
47 | await todosService.save(todos);
48 | },
49 | ),
50 | };
51 | ```
52 |
--------------------------------------------------------------------------------
/tests/testing/react.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, fireEvent } from '@testing-library/react';
3 | import {
4 | action,
5 | createStore,
6 | StoreProvider,
7 | useStoreState,
8 | useStoreActions,
9 | } from '../../src';
10 |
11 | const model = {
12 | count: 0,
13 | increment: action((state) => {
14 | state.count += 1;
15 | }),
16 | };
17 |
18 | describe('react', () => {
19 | it('component integration test', () => {
20 | // ARRANGE
21 | function ComponentUnderTest() {
22 | const count = useStoreState((state) => state.count);
23 | const increment = useStoreActions((actions) => actions.increment);
24 | return (
25 |
26 | Count: {count}
27 |
28 | +
29 |
30 |
31 | );
32 | }
33 |
34 | const store = createStore(model);
35 |
36 | const app = (
37 |
38 |
39 |
40 | );
41 |
42 | // ACT
43 | const { getByTestId, getByText } = render(app);
44 |
45 | // ASSERT
46 | expect(getByTestId('count').textContent).toEqual('0');
47 |
48 | // ACT
49 | fireEvent.click(getByText('+'));
50 |
51 | // ASSERT
52 | expect(getByTestId('count').textContent).toEqual('1');
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/website/docs/docs/api/use-store-rehydrated.md:
--------------------------------------------------------------------------------
1 | # useStoreRehydrated
2 |
3 | This hook is useful when making use of the [persist](/docs/api/persist.html) API along with an asynchronous storage engine.
4 |
5 | When you rehydrate a persisted state from an asynchronous storage engine you may experience a flash of content where your application initially renders based on your stores default state, and then when the asynchronous operation to retrieve the state completes your application rerenders with the rehydrated state.
6 |
7 | To improve your user's experience you can utilise this hook to get the status of the rehydration. Utilising the rehydration status flag allows you to conditionally render a loading state.
8 |
9 | ## Example
10 |
11 | In the example below, the ` ` content will not render until our store has been successfully updated with the rehydration state.
12 |
13 | ```javascript
14 | import { useStoreRehydrated } from 'easy-peasy';
15 |
16 | const store = createStore(persist(model, { storage: asyncStorageEngine });
17 |
18 | function App() {
19 | const rehydrated = useStoreRehydrated();
20 | return (
21 |
22 |
23 | {rehydrated ?
:
Loading...
}
24 |
25 |
26 | )
27 | }
28 |
29 | ReactDOM.render(
30 |
31 |
32 | ,
33 | document.getElementById('app')
34 | );
35 | ```
36 |
--------------------------------------------------------------------------------
/examples/reduxtagram/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "easy-peasy-example-reduxtagram",
3 | "private": true,
4 | "description": "A simple React and Easy Peasy (Redux) implementation inspired from Learn Redux course",
5 | "version": "0.0.0",
6 | "type": "module",
7 | "scripts": {
8 | "build": "tsc && vite build",
9 | "dev": "vite --port 3000",
10 | "preview": "vite preview",
11 | "test": "vitest",
12 | "lint": "eslint . --ext .ts,.tsx"
13 | },
14 | "dependencies": {
15 | "@types/react-transition-group": "^4.4.5",
16 | "easy-peasy": "^6.0.0",
17 | "nanoid": "^5.0.9",
18 | "react": "^18.2.0",
19 | "react-dom": "^18.2.0",
20 | "react-router-dom": "^6.4.1",
21 | "react-transition-group": "^4.4.5"
22 | },
23 | "devDependencies": {
24 | "@types/react": "^18.0.17",
25 | "@types/react-dom": "^18.0.6",
26 | "@typescript-eslint/eslint-plugin": "^5.38.0",
27 | "@typescript-eslint/parser": "^5.38.0",
28 | "@vitejs/plugin-react": "^2.1.0",
29 | "@vitest/coverage-c8": "^0.23.4",
30 | "@vitest/ui": "^0.23.4",
31 | "eslint": "^8.23.1",
32 | "eslint-config-prettier": "^8.5.0",
33 | "eslint-plugin-prettier": "^4.2.1",
34 | "eslint-plugin-react": "^7.31.8",
35 | "prettier": "^2.7.1",
36 | "typescript": "^4.6.4",
37 | "vite": "^3.1.0",
38 | "vite-plugin-eslint": "^1.8.1",
39 | "vite-tsconfig-paths": "^3.5.0",
40 | "vitest": "^0.23.4"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/tests/typescript/external-type-defs.ts:
--------------------------------------------------------------------------------
1 | import {
2 | action,
3 | Action,
4 | computed,
5 | Computed,
6 | thunk,
7 | Thunk,
8 | effectOn,
9 | EffectOn,
10 | } from 'easy-peasy';
11 |
12 | type MyModelEffectOn = EffectOn;
13 | type MyModelAction = Action;
14 | type MyModelComputed = Computed;
15 | type MyModelThunk = Thunk<
16 | MyModel,
17 | TPayload,
18 | any,
19 | {},
20 | Promise
21 | >;
22 |
23 | type MyState = {
24 | value: string;
25 | };
26 |
27 | type MyThunkPayload = { data: string };
28 |
29 | interface MyModel {
30 | myState: MyState;
31 |
32 | myComputed: MyModelComputed;
33 |
34 | setMyState: MyModelAction;
35 |
36 | myThunk: MyModelThunk;
37 |
38 | myEffectOn: MyModelEffectOn;
39 | }
40 |
41 | const myModel: MyModel = {
42 | myState: {
43 | value: 'initial',
44 | },
45 |
46 | setMyState: action((state, payload) => {
47 | state.myState = {
48 | value: payload,
49 | };
50 | }),
51 |
52 | myComputed: computed((state) => state.myState.value.length),
53 |
54 | myThunk: thunk(async (actions, payload) => {
55 | actions.setMyState('a');
56 | // some kind of await
57 | return true;
58 | }),
59 |
60 | myEffectOn: effectOn([(state) => state.myState.value], (actions, change) => {
61 | // do something
62 | }),
63 | };
64 |
--------------------------------------------------------------------------------
/examples/kanban/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: '@typescript-eslint/parser',
4 | parserOptions: {
5 | ecmaVersion: 2020,
6 | sourceType: 'module',
7 | ecmaFeatures: {
8 | jsx: true,
9 | },
10 | },
11 | settings: {
12 | react: {
13 | version: 'detect',
14 | },
15 | 'import/resolver': {
16 | node: {
17 | paths: ['src'],
18 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
19 | },
20 | },
21 | },
22 | env: {
23 | browser: true,
24 | amd: true,
25 | node: true,
26 | },
27 | extends: [
28 | 'eslint:recommended',
29 | 'plugin:react/recommended',
30 | 'plugin:jsx-a11y/recommended',
31 | 'plugin:prettier/recommended', // Make sure this is always the last element in the array.
32 | ],
33 | plugins: ['simple-import-sort', 'prettier'],
34 | rules: {
35 | 'prettier/prettier': ['error', {}, { usePrettierrc: true }],
36 | 'react/react-in-jsx-scope': 'off',
37 | 'jsx-a11y/accessible-emoji': 'off',
38 | 'react/prop-types': 'off',
39 | '@typescript-eslint/explicit-function-return-type': 'off',
40 | 'simple-import-sort/imports': 'error',
41 | 'simple-import-sort/exports': 'error',
42 | 'jsx-a11y/anchor-is-valid': [
43 | 'error',
44 | {
45 | components: ['Link'],
46 | specialLink: ['hrefLeft', 'hrefRight'],
47 | aspects: ['invalidHref', 'preferButton'],
48 | },
49 | ],
50 | },
51 | };
52 |
--------------------------------------------------------------------------------
/examples/simple-todo/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: '@typescript-eslint/parser',
4 | parserOptions: {
5 | ecmaVersion: 2020,
6 | sourceType: 'module',
7 | ecmaFeatures: {
8 | jsx: true,
9 | },
10 | },
11 | settings: {
12 | react: {
13 | version: 'detect',
14 | },
15 | 'import/resolver': {
16 | node: {
17 | paths: ['src'],
18 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
19 | },
20 | },
21 | },
22 | env: {
23 | browser: true,
24 | amd: true,
25 | node: true,
26 | },
27 | extends: [
28 | 'eslint:recommended',
29 | 'plugin:react/recommended',
30 | 'plugin:jsx-a11y/recommended',
31 | 'plugin:prettier/recommended', // Make sure this is always the last element in the array.
32 | ],
33 | plugins: ['simple-import-sort', 'prettier'],
34 | rules: {
35 | 'prettier/prettier': ['error', {}, { usePrettierrc: true }],
36 | 'react/react-in-jsx-scope': 'off',
37 | 'jsx-a11y/accessible-emoji': 'off',
38 | 'react/prop-types': 'off',
39 | '@typescript-eslint/explicit-function-return-type': 'off',
40 | 'simple-import-sort/imports': 'error',
41 | 'simple-import-sort/exports': 'error',
42 | 'jsx-a11y/anchor-is-valid': [
43 | 'error',
44 | {
45 | components: ['Link'],
46 | specialLink: ['hrefLeft', 'hrefRight'],
47 | aspects: ['invalidHref', 'preferButton'],
48 | },
49 | ],
50 | },
51 | };
52 |
--------------------------------------------------------------------------------
/website/docs/.vuepress/public/camera.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/kanban/src/components/TaskList/TaskView.tsx:
--------------------------------------------------------------------------------
1 | import { useStoreState, useStoreActions } from '../../store';
2 | import { StoreModel } from '../../store/model';
3 | import { Task } from '../../store/taskList.model';
4 |
5 | const TaskView: React.FC<{ task: Task; list: keyof StoreModel }> = ({ task, list }) => {
6 | const { canRegressTasks, canProgressTasks } = useStoreState((state) => state[list]);
7 | const { regressTask, progressTask, removeTask } = useStoreActions(
8 | (actions) => actions[list],
9 | );
10 | return (
11 |
12 | {task.name}
13 |
14 |
15 | {canRegressTasks && (
16 | regressTask(task)}
20 | >
21 | ⏮️
22 |
23 | )}
24 | removeTask(task)}
28 | >
29 | ❌
30 |
31 | {canProgressTasks && (
32 | progressTask(task)}
36 | >
37 | ⏭️
38 |
39 | )}
40 |
41 |
42 | );
43 | };
44 |
45 | export default TaskView;
46 |
--------------------------------------------------------------------------------
/tests/server-rendering.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment node
3 | */
4 |
5 | import React from 'react';
6 | import { renderToString } from 'react-dom/server';
7 | import { createStore, useStoreState, StoreProvider, action } from '../src';
8 |
9 | test('works', () => {
10 | // ARRANGE
11 | const store = createStore({
12 | count: 0,
13 | });
14 |
15 | function Count() {
16 | const count = useStoreState((state) => state.count);
17 | return {count} ;
18 | }
19 | const app = (
20 |
21 |
22 |
23 | );
24 |
25 | // ACT
26 | const actual = renderToString(app);
27 |
28 | // ASSERT
29 | expect(actual).toEqual('0 ');
30 | });
31 |
32 | test('works with inital state', () => {
33 | // ARRANGE
34 | const store = createStore(
35 | {
36 | count: null,
37 | bump: action((state) => {
38 | state.count += state.increment;
39 | }),
40 | },
41 | {
42 | count: 0,
43 | initialState: {
44 | increment: 5,
45 | },
46 | },
47 | );
48 |
49 | store.getActions().bump();
50 |
51 | function Count() {
52 | const count = useStoreState((state) => state.count);
53 | return {count} ;
54 | }
55 | const app = (
56 |
57 |
58 |
59 | );
60 |
61 | // ACT
62 | const actual = renderToString(app);
63 |
64 | // ASSERT
65 | expect(actual).toEqual('5 ');
66 | });
67 |
--------------------------------------------------------------------------------
/examples/react-native-todo/android/app/src/main/java/com/reactnativetodo/MainActivity.java:
--------------------------------------------------------------------------------
1 | package com.reactnativetodo;
2 |
3 | import com.facebook.react.ReactActivity;
4 | import com.facebook.react.ReactActivityDelegate;
5 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
6 | import com.facebook.react.defaults.DefaultReactActivityDelegate;
7 |
8 | public class MainActivity extends ReactActivity {
9 |
10 | /**
11 | * Returns the name of the main component registered from JavaScript. This is used to schedule
12 | * rendering of the component.
13 | */
14 | @Override
15 | protected String getMainComponentName() {
16 | return "ReactNativeTodo";
17 | }
18 |
19 | /**
20 | * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link
21 | * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React
22 | * (aka React 18) with two boolean flags.
23 | */
24 | @Override
25 | protected ReactActivityDelegate createReactActivityDelegate() {
26 | return new DefaultReactActivityDelegate(
27 | this,
28 | getMainComponentName(),
29 | // If you opted-in for the New Architecture, we enable the Fabric Renderer.
30 | DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled
31 | // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
32 | DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
33 | );
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tests/typescript/react.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import {
4 | Action,
5 | createStore,
6 | State,
7 | StoreProvider,
8 | createTypedHooks,
9 | } from 'easy-peasy';
10 | import { connect } from 'react-redux';
11 | import { createRoot } from 'react-dom/client';
12 |
13 | interface StoreModel {
14 | items: Array;
15 | addTodo: Action;
16 | }
17 |
18 | const model: StoreModel = {} as any;
19 |
20 | const store = createStore(model);
21 |
22 | const { useStoreState, useStoreActions, useStoreDispatch } =
23 | createTypedHooks();
24 |
25 | function MyComponent() {
26 | const items = useStoreState((state) => state.items);
27 | const addTodo = useStoreActions((actions) => actions.addTodo);
28 | addTodo('Install easy peasy');
29 | const dispatch = useStoreDispatch();
30 | dispatch({
31 | type: 'ADD_FOO',
32 | payload: 'bar',
33 | });
34 | return (
35 |
36 | {items.map((item) => (
37 |
{`Todo: ${item}`}
38 | ))}
39 |
40 | );
41 | }
42 |
43 | const root = createRoot(document.createElement('div'));
44 | root.render(
45 |
46 |
47 | ,
48 | );
49 |
50 | /**
51 | * We also support typing react-redux
52 | */
53 | const Todos: React.FC<{ todos: string[] }> = ({ todos }) => (
54 | Count: {todos.length}
55 | );
56 |
57 | connect((state: State) => ({
58 | items: state.items,
59 | }))(Todos);
60 |
--------------------------------------------------------------------------------
/examples/kanban/README.md:
--------------------------------------------------------------------------------
1 | # Kanban example ([View codesandbox](https://codesandbox.io/s/5zdk6r))
2 |
3 | Kanban example of `react` and `easy-peasy`.
4 |
5 | 
6 |
7 | This is a `Vite + React + Typescript + Eslint + Prettier` example based on [this template](https://github.com/TheSwordBreaker/vite-reactts-eslint-prettier).
8 |
9 | The example also includes tests using `vitest`, `@testing-library/react` & `@testing-library/user-event`.
10 |
11 | ## Getting Started
12 |
13 | First, run the development server:
14 |
15 | ```bash
16 | yarn dev
17 | ```
18 |
19 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
20 |
21 | You can start editing the page by modifying `src/components/App.tsx`. The page auto-updates as you edit the file.
22 |
23 | The `easy-peasy` store & models are located in the `src/store` folder.
24 | The `main.tsx` file wraps the ` ` component with the ``, so all child components can access the
25 | hooks exposed from the `store/index.ts`.
26 |
27 | [Session storage persistance](https://easy-peasy.vercel.app/docs/api/persist.html) is used for this app, setup in the `store/index.ts`.
28 |
29 | ## Testing
30 |
31 | This example is using the `vitest` engine, but the same principles & consepts can also be applied for the `jest` engine.
32 |
33 | Execute the tests by running
34 |
35 | ```bash
36 | yarn test
37 | ```
38 |
39 | - See `store/model.test.ts` for an example of how to test models.
40 | - See `components/**/*.test.tsx` for an example of how to test views. (Utilizing `utils/test-utils.tsx` to setup each test case)
41 |
--------------------------------------------------------------------------------
/examples/simple-todo/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, useState } from 'react';
2 | import { Todo } from './store/model';
3 | import { useStoreState, useStoreActions } from './store';
4 |
5 | const App = () => {
6 | const { remainingTodos, completedTodos } = useStoreState((state) => state);
7 |
8 | return (
9 |
10 |
Todo list
11 |
12 | {[...remainingTodos, ...completedTodos].map((todo, idx) => (
13 |
14 |
15 |
16 | ))}
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => {
25 | const { toggleTodo } = useStoreActions((actions) => actions);
26 |
27 | const Wrapper = todo.done ? 's' : Fragment;
28 | return (
29 |
30 | toggleTodo(todo)}
35 | />
36 | {todo.text}
37 |
38 | );
39 | };
40 |
41 | const AddTodo = () => {
42 | const [todoText, setTodoText] = useState('');
43 | const { addTodo } = useStoreActions((actions) => actions);
44 |
45 | return (
46 | {
48 | e.preventDefault();
49 | addTodo({ text: todoText, done: false });
50 | setTodoText('');
51 | }}
52 | >
53 | setTodoText(e.target.value)} />
54 |
55 | Add todo
56 |
57 |
58 | );
59 | };
60 |
61 | export default App;
62 |
--------------------------------------------------------------------------------
/website/docs/docs/api/create-store.md:
--------------------------------------------------------------------------------
1 | # createStore
2 |
3 | Creates a global [store](/docs/api/store.html) based on the provided model. It
4 | supports a [configuration](/docs/api/store-config.html) parameter to customise
5 | your [store's](/docs/api/store.html) behaviour.
6 |
7 | ```javascript
8 | import { createStore } from 'easy-peasy';
9 |
10 | const store = createStore({
11 | todos: {
12 | items: [],
13 | },
14 | });
15 | ```
16 |
17 | ## Arguments
18 |
19 | The following arguments are accepted:
20 |
21 | - `model` (Object, _required_)
22 |
23 | Your model representing your store.
24 |
25 | - `config` (Object, _optional_)
26 |
27 | Provides custom configuration options for your store. Please see the
28 | [StoreConfig](/docs/api/store-config.html) API documentation for a full list
29 | of configuration options.
30 |
31 | ## Returns
32 |
33 | When executed, you will receive a [store](/docs/api/store.html) instance back.
34 | Please refer to the [docs](/docs/api/store.html) for details of the store's API.
35 |
36 | Once you have a store you provide it to the
37 | [StoreProvider](/docs/api/store-provider.html) in order to expose it to your
38 | application.
39 |
40 | ## Example
41 |
42 | ```javascript
43 | import { createStore, StoreProvider, action } from 'easy-peasy';
44 |
45 | const model = {
46 | todos: {
47 | items: [],
48 | addTodo: action((state, text) => {
49 | state.items.push(text);
50 | }),
51 | },
52 | };
53 |
54 | const store = createStore(model, {
55 | name: 'MyAwesomeStore',
56 | });
57 |
58 | ReactDOM.render(
59 |
60 |
61 | ,
62 | document.querySelector('#app'),
63 | );
64 | ```
65 |
--------------------------------------------------------------------------------
/examples/kanban/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "easy-peasy-example-kanban",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "dev": "vite --port 3000",
6 | "build": "tsc && vite build",
7 | "serve": "vite preview",
8 | "test": "vitest",
9 | "lint:fix": "eslint ./src --ext .jsx,.js,.ts,.tsx --quiet --fix --ignore-path ./.gitignore",
10 | "lint:format": "prettier --loglevel warn --write \"./**/*.{js,jsx,ts,tsx,css,md,json}\" ",
11 | "lint": "yarn lint:format && yarn lint:fix ",
12 | "type-check": "tsc"
13 | },
14 | "dependencies": {
15 | "easy-peasy": "^6.0.0",
16 | "react": "^19.0.0",
17 | "react-dom": "^19.0.0"
18 | },
19 | "devDependencies": {
20 | "@tailwindcss/forms": "^0.5.3",
21 | "@testing-library/jest-dom": "^6.6.3",
22 | "@testing-library/react": "^16.2.0",
23 | "@testing-library/dom": "^10.0.0",
24 | "@testing-library/user-event": "^14.6.1",
25 | "@types/react": "^19.0.10",
26 | "@types/react-dom": "^19.0.4",
27 | "@typescript-eslint/eslint-plugin": "^5.10.2",
28 | "@typescript-eslint/parser": "^5.10.2",
29 | "@vitejs/plugin-react": "^1.3.2",
30 | "autoprefixer": "^10.4.11",
31 | "eslint": "^8.8.0",
32 | "eslint-config-prettier": "^8.3.0",
33 | "eslint-plugin-import": "^2.25.4",
34 | "eslint-plugin-jsx-a11y": "^6.5.1",
35 | "eslint-plugin-prettier": "^4.0.0",
36 | "eslint-plugin-react": "^7.28.0",
37 | "eslint-plugin-simple-import-sort": "^7.0.0",
38 | "postcss": "^8.4.16",
39 | "pre-commit": "^1.2.2",
40 | "prettier": "^2.5.1",
41 | "tailwindcss": "^3.1.8",
42 | "typescript": "^4.5.5",
43 | "vite": "^3.1.1",
44 | "vitest": "^0.23.4"
45 | },
46 | "pre-commit": "lint",
47 | "license": "MIT"
48 | }
49 |
--------------------------------------------------------------------------------
/examples/reduxtagram/src/model/comments-model.ts:
--------------------------------------------------------------------------------
1 | import { Action, action, Computed, computed } from 'easy-peasy';
2 | import { nanoid } from 'nanoid';
3 |
4 | export interface Comment {
5 | id: string;
6 | text: string;
7 | user: string;
8 | }
9 |
10 | export interface PostComments {
11 | [postId: string]: Comment[] | undefined;
12 | }
13 |
14 | export interface AddCommentPayload extends Omit {
15 | postId: string;
16 | }
17 |
18 | export interface RemoveCommentPayload {
19 | postId: string;
20 | commentId: string;
21 | }
22 |
23 | export interface CommentsModel {
24 | comments: PostComments;
25 |
26 | byPostId: Computed Comment[]>;
27 |
28 | addComment: Action;
29 | removeComment: Action;
30 | }
31 |
32 | export const commentsModel: CommentsModel = {
33 | comments: {},
34 |
35 | byPostId: computed((state) => (postId: string) => state.comments[postId] || []),
36 |
37 | addComment: action((state, payload) => {
38 | const { postId, text, user } = payload;
39 | let comments = state.comments[postId];
40 |
41 | if (!Array.isArray(comments)) {
42 | comments = state.comments[postId] = [];
43 | }
44 |
45 | comments.push({
46 | id: nanoid(),
47 | text,
48 | user,
49 | });
50 | }),
51 |
52 | removeComment: action((state, payload) => {
53 | const { postId, commentId } = payload;
54 | const comments = state.comments[postId];
55 |
56 | if (Array.isArray(comments)) {
57 | const index = comments.findIndex((comment) => comment.id === commentId);
58 | if (index > -1) {
59 | comments.splice(index, 1);
60 | }
61 | }
62 | }),
63 | };
64 |
--------------------------------------------------------------------------------
/tests/reducer.test.js:
--------------------------------------------------------------------------------
1 | import { action, createStore, reducer } from '../src';
2 |
3 | it('basic', () => {
4 | // ARRANGE
5 | const store = createStore({
6 | counter: reducer((state = 1, _action = {}) => {
7 | if (_action.type === 'INCREMENT') {
8 | return state + 1;
9 | }
10 | return state;
11 | }),
12 | foo: {
13 | bar: 'baz',
14 | update: action((state) => {
15 | state.bar = 'bob';
16 | }),
17 | },
18 | });
19 |
20 | // ASSERT
21 | expect(store.getState().counter).toEqual(1);
22 |
23 | // ACT
24 | store.dispatch({ type: 'INCREMENT' });
25 |
26 | // ASSERT
27 | expect(store.getState()).toEqual({
28 | counter: 2,
29 | foo: {
30 | bar: 'baz',
31 | },
32 | });
33 | });
34 |
35 | it('nested', () => {
36 | // ARRANGE
37 | const store = createStore({
38 | stuff: {
39 | counter: reducer((state = 1, _action = {}) => {
40 | if (_action.type === 'INCREMENT') {
41 | return state + 1;
42 | }
43 | return state;
44 | }),
45 | },
46 | });
47 |
48 | // ACT
49 | store.dispatch({ type: 'INCREMENT' });
50 |
51 | // ASSERT
52 | expect(store.getState()).toEqual({
53 | stuff: {
54 | counter: 2,
55 | },
56 | });
57 | });
58 |
59 | it('no-op', () => {
60 | // ARRANGE
61 | const store = createStore({
62 | counter: reducer((state = 1, _action = {}) => {
63 | if (_action.type === 'INCREMENT') {
64 | return state + 1;
65 | }
66 | return state;
67 | }),
68 | doNothing: action((state) => state),
69 | });
70 |
71 | const initial = store.getState();
72 |
73 | // ACT
74 | store.getActions().doNothing();
75 |
76 | // ASSERT
77 | expect(store.getState()).toBe(initial);
78 | });
79 |
--------------------------------------------------------------------------------
/examples/kanban/src/store/taskList.model.ts:
--------------------------------------------------------------------------------
1 | import {
2 | action,
3 | Action,
4 | Actions,
5 | computed,
6 | Computed,
7 | Store,
8 | thunk,
9 | Thunk,
10 | } from 'easy-peasy';
11 | import { StoreModel } from './model';
12 |
13 | export interface Task {
14 | id: string;
15 | name: string;
16 | }
17 |
18 | interface TaskListModelState {
19 | name: string;
20 | tasks: Task[];
21 |
22 | regressTasksTo?: keyof StoreModel;
23 | progressTasksTo?: keyof StoreModel;
24 | }
25 |
26 | export interface TaskListModel extends TaskListModelState {
27 | canRegressTasks: Computed;
28 | canProgressTasks: Computed;
29 |
30 | addTask: Action;
31 | removeTask: Action;
32 |
33 | regressTask: Thunk;
34 | progressTask: Thunk;
35 | }
36 |
37 | const createTaskListStore = (initialState: TaskListModelState): TaskListModel => ({
38 | ...initialState,
39 |
40 | canRegressTasks: computed((state) => !!state.regressTasksTo),
41 | canProgressTasks: computed((state) => !!state.progressTasksTo),
42 |
43 | addTask: action((state, task) => {
44 | state.tasks.push(task);
45 | }),
46 | removeTask: action((state, task) => {
47 | state.tasks = state.tasks.filter((t) => t.id !== task.id);
48 | }),
49 |
50 | regressTask: moveTask(initialState.regressTasksTo!),
51 | progressTask: moveTask(initialState.progressTasksTo!),
52 | });
53 |
54 | const moveTask = (to: keyof StoreModel) =>
55 | thunk((actions, task, { getStoreActions }) => {
56 | const storeActions = getStoreActions();
57 |
58 | actions.removeTask(task);
59 | storeActions[to].addTask(task);
60 | });
61 |
62 | export default createTaskListStore;
63 |
--------------------------------------------------------------------------------
/website/docs/docs/api/create-transform.md:
--------------------------------------------------------------------------------
1 | # createTransform
2 |
3 | Creates a transformer, which can be applied to [`persist`](/docs/api/persist.html) configurations.
4 |
5 | Transformers are use to apply operations to your data during prior it being persisted or hydrated.
6 |
7 | One use case for a transformer is to handle data that can't be parsed to a JSON string. For example a `Map` or `Set`. To handle these data types you could utilise a transformer that converts the `Map`/`Set` to/from an `Array` or `Object`.
8 |
9 | This helper has been directly copied from [`redux-persist`](https://github.com/rt2zz/redux-persist), with the intention of maximising our compatibility with the `redux-persist` ecosystem.
10 |
11 | > [`redux-persist`](https://github.com/rt2zz/redux-persist) already has a robust set of [transformer packages](https://github.com/rt2zz/redux-persist#transforms) that have been built for it.
12 |
13 | ## API
14 |
15 | The function accepts the following arguments:
16 |
17 | - `inbound` (data: any, key: string, fullState: any) => any; *optional*
18 |
19 | This function will be executed against data prior to it being persisted by the configured storage engine.
20 |
21 | - `outbound` (data: any, key: string, fullState: any) => any; *optional*
22 |
23 | This function will be executed against data prior after it is extracted from the configured storage engine.
24 |
25 | - `configuration` Object; *optional*
26 |
27 | Additional configuration for the transform. An object supporting the following properties.
28 |
29 | - `whitelist` Array<string>; *optional*
30 |
31 | The data keys that this transformer would apply to.
32 |
33 | - `blacklist` Array<string>; *optional*
34 |
35 | The data keys that this transformer would not apply to.
36 |
37 |
--------------------------------------------------------------------------------
/website/docs/docs/tutorials/README.md:
--------------------------------------------------------------------------------
1 | # Tutorials
2 |
3 | These tutorials varying degrees of insight into Easy Peasy.
4 |
5 | If this is your first time here we suggest that you jump into the
6 | [Quick Start](/docs/tutorials/quick-start.html).
7 |
8 | - [Quick Start](/docs/tutorials/quick-start.html)
9 |
10 | The best place to start if you are a newcomer. It will give you a light and
11 | practical introduction to the primary API so that you can familiarize yourself
12 | with Easy Peasy without being overloaded with additional information.
13 |
14 | Taking the learnings from here you should be able to jump straight into
15 | creating and managing your own Easy Peasy powered state. Once you gained some
16 | comfort and wish for some additional insight then we would suggest coming back
17 | and working through the other tutorials as needed.
18 |
19 | - [Primary API](/docs/tutorials/primary-api.html)
20 |
21 | Within this tutorial we go into depth on the primary Easy Peasy API, touching
22 | on some of the important characteristics and caveats.
23 |
24 | This is a helpful companion to those that have adopted Easy Peasy and now wish
25 | to solidify their understanding of the library.
26 |
27 | - [Extended API](/docs/tutorials/extended-api.html)
28 |
29 | In this tutorial we will dive deeper into the aspects of the Easy Peasy API
30 | typically used to solve more advanced / specialized use cases.
31 |
32 | Utilization of these APIs should generally be the exception, rather than the
33 | rule.
34 |
35 | - [TypeScript](/docs/tutorials/typescript.html)
36 |
37 | Easy Peasy comes with extensive TypeScript support, allowing you to achieve a
38 | 100% end-to-end typed experience.
39 |
40 | This tutorial will provide a full overview on how to combine TypeScript and
41 | Easy Peasy.
42 |
--------------------------------------------------------------------------------
/src/listeners.js:
--------------------------------------------------------------------------------
1 | import { get } from './lib';
2 |
3 | export function createListenerMiddleware(_r) {
4 | return () => (next) => (action) => {
5 | const result = next(action);
6 | if (
7 | action &&
8 | _r._i._lAM[action.type] &&
9 | _r._i._lAM[action.type].length > 0
10 | ) {
11 | const sourceAction = _r._i._aCD[action.type];
12 | _r._i._lAM[action.type].forEach((actionCreator) => {
13 | actionCreator({
14 | type: sourceAction ? sourceAction.def.meta.type : action.type,
15 | payload: action.payload,
16 | error: action.error,
17 | result: action.result,
18 | });
19 | });
20 | }
21 | return result;
22 | };
23 | }
24 |
25 | export function bindListenerdefs(listenerdefs, _aC, _aCD, _lAM) {
26 | listenerdefs.forEach((def) => {
27 | const targets = def.targetResolver(get(def.meta.parent, _aC), _aC);
28 |
29 | const targetTypes = (Array.isArray(targets) ? targets : [targets]).reduce(
30 | (acc, target) => {
31 | if (
32 | typeof target === 'function' &&
33 | target.def.meta.type &&
34 | _aCD[target.def.meta.type]
35 | ) {
36 | if (target.def.meta.successType) {
37 | acc.push(target.def.meta.successType);
38 | } else {
39 | acc.push(target.def.meta.type);
40 | }
41 | } else if (typeof target === 'string') {
42 | acc.push(target);
43 | }
44 | return acc;
45 | },
46 | [],
47 | );
48 |
49 | def.meta.resolvedTargets = targetTypes;
50 |
51 | targetTypes.forEach((targetType) => {
52 | const listenerReg = _lAM[targetType] || [];
53 | listenerReg.push(_aCD[def.meta.type]);
54 | _lAM[targetType] = listenerReg;
55 | });
56 | });
57 | }
58 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import babel from 'rollup-plugin-babel';
3 | import resolve from 'rollup-plugin-node-resolve';
4 | import analyze from 'rollup-plugin-analyzer';
5 | import pkg from './package.json';
6 |
7 | const babelRuntimeVersion = pkg.dependencies['@babel/runtime'].replace(
8 | /^[^0-9]*/,
9 | '',
10 | );
11 |
12 | const { root } = path.parse(process.cwd());
13 |
14 | const external = (id) => !id.startsWith('.') && !id.startsWith(root);
15 |
16 | const extensions = ['.js'];
17 |
18 | function createESMConfig(input, output) {
19 | return {
20 | input,
21 | output: { sourcemap: true, file: output, format: 'esm' },
22 | external,
23 | plugins: [
24 | resolve({ extensions }),
25 | babel({
26 | extensions,
27 | plugins: [
28 | ['@babel/plugin-transform-runtime', { version: babelRuntimeVersion }],
29 | ],
30 | runtimeHelpers: true,
31 | sourceMaps: true,
32 | inputSourceMap: true,
33 | }),
34 | analyze({ summaryOnly: true }),
35 | ],
36 | };
37 | }
38 |
39 | function createCommonJSConfig(input, output) {
40 | return {
41 | input,
42 | output: { sourcemap: true, file: output, format: 'cjs', exports: 'named' },
43 | external,
44 | plugins: [
45 | resolve({ extensions }),
46 | babel({
47 | extensions,
48 | plugins: [
49 | ['@babel/plugin-transform-runtime', { version: babelRuntimeVersion }],
50 | ],
51 | runtimeHelpers: true,
52 | sourceMaps: true,
53 | inputSourceMap: true,
54 | }),
55 | analyze({ summaryOnly: true }),
56 | ],
57 | };
58 | }
59 |
60 | export default [
61 | createESMConfig('src/index.js', 'dist/index.js'),
62 | createCommonJSConfig('src/index.js', 'dist/index.cjs.js'),
63 | ];
64 |
--------------------------------------------------------------------------------
/examples/nextjs-todo/store/model.ts:
--------------------------------------------------------------------------------
1 | import {
2 | action,
3 | Action,
4 | computed,
5 | Computed,
6 | thunk,
7 | Thunk,
8 | thunkOn,
9 | ThunkOn,
10 | } from 'easy-peasy';
11 |
12 | export interface Todo {
13 | text: string;
14 | done: boolean;
15 | }
16 |
17 | export interface TodosModel {
18 | todos: Todo[];
19 |
20 | completedTodos: Computed;
21 | remainingTodos: Computed;
22 |
23 | addTodo: Action;
24 | toggleTodo: Action;
25 |
26 | onTodosChanged: ThunkOn;
27 | saveTodos: Thunk;
28 | }
29 |
30 | const todosStore: TodosModel = {
31 | todos: [
32 | { text: 'Create store', done: true },
33 | { text: 'Wrap application', done: true },
34 | { text: 'Use store', done: true },
35 | { text: 'Add a todo', done: false },
36 | ],
37 |
38 | completedTodos: computed((state) => state.todos.filter((todo) => todo.done)),
39 | remainingTodos: computed((state) => state.todos.filter((todo) => !todo.done)),
40 |
41 | addTodo: action((state, payload) => {
42 | state.todos.push(payload);
43 | }),
44 | toggleTodo: action((state, todoToToggle) => {
45 | const updatedTodos = state.todos.map((todo) =>
46 | todo.text === todoToToggle.text ? { ...todo, done: !todo.done } : todo,
47 | );
48 | state.todos = updatedTodos;
49 | }),
50 |
51 | onTodosChanged: thunkOn(
52 | (actions) => [actions.addTodo, actions.toggleTodo],
53 | (actions, payload, { getState }) => {
54 | console.log(`onTodosChanged triggered by `, payload);
55 | actions.saveTodos(getState().todos);
56 | },
57 | ),
58 | saveTodos: thunk((actions, todosToSave) => {
59 | console.log(`Imagine were sending ${todosToSave.length} todos to a remote server..`);
60 | }),
61 | };
62 |
63 | export default todosStore;
64 |
--------------------------------------------------------------------------------
/examples/simple-todo/src/store/model.ts:
--------------------------------------------------------------------------------
1 | import {
2 | action,
3 | Action,
4 | computed,
5 | Computed,
6 | thunk,
7 | Thunk,
8 | thunkOn,
9 | ThunkOn,
10 | } from 'easy-peasy';
11 |
12 | export interface Todo {
13 | text: string;
14 | done: boolean;
15 | }
16 |
17 | export interface TodosModel {
18 | todos: Todo[];
19 |
20 | completedTodos: Computed;
21 | remainingTodos: Computed;
22 |
23 | addTodo: Action;
24 | toggleTodo: Action;
25 |
26 | onTodosChanged: ThunkOn;
27 | saveTodos: Thunk;
28 | }
29 |
30 | const todosStore: TodosModel = {
31 | todos: [
32 | { text: 'Create store', done: true },
33 | { text: 'Wrap application', done: true },
34 | { text: 'Use store', done: true },
35 | { text: 'Add a todo', done: false },
36 | ],
37 |
38 | completedTodos: computed((state) => state.todos.filter((todo) => todo.done)),
39 | remainingTodos: computed((state) => state.todos.filter((todo) => !todo.done)),
40 |
41 | addTodo: action((state, payload) => {
42 | state.todos.push(payload);
43 | }),
44 | toggleTodo: action((state, todoToToggle) => {
45 | const updatedTodos = state.todos.map((todo) =>
46 | todo.text === todoToToggle.text ? { ...todo, done: !todo.done } : todo,
47 | );
48 | state.todos = updatedTodos;
49 | }),
50 |
51 | onTodosChanged: thunkOn(
52 | (actions) => [actions.addTodo, actions.toggleTodo],
53 | (actions, payload, { getState }) => {
54 | console.log(`onTodosChanged triggered by `, payload);
55 | actions.saveTodos(getState().todos);
56 | },
57 | ),
58 | saveTodos: thunk((actions, todosToSave) => {
59 | console.log(`Imagine were sending ${todosToSave.length} todos to a remote server..`);
60 | }),
61 | };
62 |
63 | export default todosStore;
64 |
--------------------------------------------------------------------------------
/src/create-reducer.js:
--------------------------------------------------------------------------------
1 | import { isDraft, original } from 'immer';
2 | import { createSimpleProduce, get } from './lib';
3 |
4 | export default function createReducer(disableImmer, _aRD, _cR, _cP) {
5 | const simpleProduce = createSimpleProduce(disableImmer);
6 |
7 | const runActionReducerAtPath = (state, action, actionReducer, path, config) =>
8 | simpleProduce(path, state, (draft) => actionReducer(draft, action.payload), config);
9 |
10 | const reducerForActions = (state, action) => {
11 | const actionReducer = _aRD[action.type];
12 | if (actionReducer) {
13 | return runActionReducerAtPath(
14 | state,
15 | action,
16 | actionReducer,
17 | actionReducer.def.meta.parent,
18 | actionReducer.def.config,
19 | );
20 | }
21 | return state;
22 | };
23 |
24 | const reducerForCustomReducers = (state, action) =>
25 | _cR.reduce(
26 | (acc, { parentPath, key, reducer, config }) =>
27 | simpleProduce(parentPath, acc, (draft) => {
28 | draft[key] = reducer(
29 | isDraft(draft[key]) ? original(draft[key]) : draft[key],
30 | action,
31 | );
32 | return draft;
33 | }, config),
34 | state,
35 | );
36 |
37 | const rootReducer = (state, action) => {
38 | const stateAfterActions = reducerForActions(state, action);
39 | const next =
40 | _cR.length > 0
41 | ? reducerForCustomReducers(stateAfterActions, action)
42 | : stateAfterActions;
43 | if (state !== next) {
44 | _cP.forEach(({ parentPath, bindComputedProperty }) => {
45 | const parentState = get(parentPath, next);
46 | if (parentState != null) bindComputedProperty(parentState, next);
47 | });
48 | }
49 | return next;
50 | };
51 |
52 | return rootReducer;
53 | }
54 |
--------------------------------------------------------------------------------
/examples/reduxtagram/src/components/photo.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { CSSTransition } from 'react-transition-group';
4 | import { useStoreActions } from '@/hooks';
5 | import { Comment, Post } from '@/model';
6 |
7 | interface Props {
8 | post: Post;
9 | comments: Comment[]; // comments related to this post
10 | }
11 |
12 | export default function Photo({ post, comments }: Props) {
13 | const [appear, setAppear] = useState(false);
14 | const likePost = useStoreActions((actions) => actions.postsModel.likePost);
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
setAppear(false)}
28 | >
29 | {post.likes}
30 |
31 |
32 |
33 |
34 | {post.caption}
35 |
36 |
37 | {
39 | likePost(post.id);
40 | setAppear(true);
41 | }}
42 | className="likes"
43 | >
44 | ♥ {post.likes}
45 |
46 |
47 |
48 |
49 | {comments.length}
50 |
51 |
52 |
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/examples/react-native-todo/ios/ReactNativeTodo/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleDisplayName
8 | ReactNativeTodo
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | $(MARKETING_VERSION)
21 | CFBundleSignature
22 | ????
23 | CFBundleVersion
24 | $(CURRENT_PROJECT_VERSION)
25 | LSRequiresIPhoneOS
26 |
27 | NSAppTransportSecurity
28 |
29 | NSExceptionDomains
30 |
31 | localhost
32 |
33 | NSExceptionAllowsInsecureHTTPLoads
34 |
35 |
36 |
37 |
38 | NSLocationWhenInUseUsageDescription
39 |
40 | UILaunchStoryboardName
41 | LaunchScreen
42 | UIRequiredDeviceCapabilities
43 |
44 | armv7
45 |
46 | UISupportedInterfaceOrientations
47 |
48 | UIInterfaceOrientationPortrait
49 | UIInterfaceOrientationLandscapeLeft
50 | UIInterfaceOrientationLandscapeRight
51 |
52 | UIViewControllerBasedStatusBarAppearance
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/computed.md:
--------------------------------------------------------------------------------
1 | # Computed
2 |
3 | Defines a [computed](/docs/api/computed.html) property against your model.
4 |
5 | ## API
6 |
7 | ```typescript
8 | Computed<
9 | Model extends object = {},
10 | Result = any,
11 | StoreModel extends object = {}
12 | >
13 | ```
14 |
15 | - `Model`
16 |
17 | The model against which the [computed](/docs/api/computed.html) property is being defined. You need to provide this so that the state that will be provided to your [computed](/docs/api/computed.html) property is correctly typed.
18 |
19 | - `Result`
20 |
21 | The type of the derived data that will be returned by your [computed](/docs/api/computed.html) property.
22 |
23 | - `StoreModel`
24 |
25 | If you expect to using state resolvers within your [computed](/docs/api/computed.html) property implementation which use the entire store state then you will need to provide your store's model interface so that the store state is correctly typed.
26 |
27 |
28 | ## Example
29 |
30 | ```typescript
31 | import { Computed, computed } from 'easy-peasy';
32 |
33 | interface TodosModel {
34 | todos: string[];
35 | count: Computed;
36 | }
37 |
38 | const todosModel: TodosModel = {
39 | todos: [],
40 | count: computed(state => state.todos.length)
41 | }
42 | ```
43 |
44 | ## Example with state resolvers using store state
45 |
46 | ```typescript
47 | import { Computed, computed } from 'easy-peasy';
48 | import { StoreModel } from './index';
49 |
50 | interface BasketModel {
51 | productIds: string[];
52 | products: Computed;
53 | }
54 |
55 | const basketModel: BasketModel = {
56 | productIds: [],
57 | products: computed(
58 | [
59 | state => state.productIds,
60 | (state, storeState) => storeState.products.items
61 | ],
62 | (productIds, products) => productIds.map(id => products[id])
63 | )
64 | }
65 | ```
--------------------------------------------------------------------------------
/website/docs/docs/recipes/usage-with-react-redux.md:
--------------------------------------------------------------------------------
1 | # Usage with react-redux
2 |
3 | As Easy Peasy outputs a standard Redux store, so it is entirely possible to use Easy Peasy with the official [`react-redux`](https://github.com/reduxjs/react-redux) package.
4 |
5 | This allows you to do a few things:
6 |
7 | - Slowly migrate a legacy application that is built using `react-redux`
8 | - Connect your store to Class components via `useDispatch and useSelectors`
9 |
10 | **1. First, install the `react-redux` package**
11 |
12 | ```bash
13 | npm install react-redux
14 | ```
15 |
16 | **2. Then wrap your app with the `Provider`**
17 |
18 | ```javascript
19 | import React from 'react';
20 | import { render } from 'react-dom';
21 | import { createStore } from 'easy-peasy';
22 | import { Provider } from 'react-redux'; // 👈 import the provider
23 | import model from './model';
24 | import TodoList from './components/TodoList';
25 |
26 | // 👇 then create your store
27 | const store = createStore(model);
28 |
29 | const App = () => (
30 | // 👇 then pass it to the Provider
31 |
32 |
33 |
34 | );
35 |
36 | render( , document.querySelector('#app'));
37 | ```
38 |
39 | **3. Finally, use `useSelector` and `useDispatch` against your components**
40 |
41 | ```javascript
42 | import React from 'react';
43 | import { useSelector, useStoreActions } from "./model"; // 👈 For connecting the store with the components and dispatching actions
44 |
45 | const TodoList = ({ todos, addTodo }) => {
46 | const todos = useSelector(state => state.todos.items);
47 | const addTodo = useStoreActions(dispatch => dispatch.todos.addTodo);
48 |
49 | return (
50 |
51 | {todos.map(({ id, text }) =>
52 |
53 | )}
54 |
55 |
56 | );
57 | }
58 |
59 | export default TodoList;
60 | ```
61 |
--------------------------------------------------------------------------------
/tests/typescript/persist.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { createTransform, persist, useStoreRehydrated } from 'easy-peasy';
3 |
4 | persist({
5 | foo: 'bar',
6 | });
7 |
8 | const model = persist(
9 | {
10 | foo: 'bar',
11 | },
12 | {
13 | allow: ['foo'],
14 | deny: ['foo'],
15 | mergeStrategy: 'mergeShallow',
16 | storage: 'sessionStorage',
17 | transformers: [
18 | {
19 | in: (data, key) => `${key}foo`,
20 | out: (data, key) => `${key}foo`,
21 | },
22 | ],
23 | },
24 | );
25 |
26 | `${model.foo}baz`;
27 |
28 | persist(
29 | {
30 | foo: 'bar',
31 | },
32 | {
33 | migrations: {
34 | migrationVersion: 1,
35 |
36 | 1: (state) => {
37 | state.foo = 'bar';
38 | delete state.migrationConflict
39 | }
40 | },
41 | },
42 | );
43 |
44 | createTransform(
45 | (data, key) => `${key}foo`,
46 | (data, key) => `${key}foo`,
47 | {
48 | whitelist: ['foo'],
49 | blacklist: ['foo'],
50 | },
51 | );
52 |
53 | createTransform(
54 | (data, key) => `${key}foo`,
55 | (data, key) => `${key}foo`,
56 | );
57 |
58 | createTransform((data, key, fullState) => `${key}foo`);
59 |
60 | createTransform(undefined, (data, key) => `${key}foo`, {
61 | whitelist: ['foo'],
62 | blacklist: ['foo'],
63 | });
64 |
65 | createTransform(undefined, undefined, {
66 | whitelist: ['foo'],
67 | blacklist: ['foo'],
68 | });
69 |
70 | const transformer = createTransform(
71 | (data, key) => `${key}foo`,
72 | (data, key) => `${key}foo`,
73 | {
74 | whitelist: ['foo'],
75 | blacklist: ['foo'],
76 | },
77 | );
78 |
79 | if (transformer.in) {
80 | transformer.in('foo', 'bar');
81 | }
82 |
83 | if (transformer.out) {
84 | transformer.out('foo', 'bar');
85 | }
86 |
87 | function App() {
88 | const rehydrated = useStoreRehydrated();
89 | return rehydrated ? Loaded
: Loading...
;
90 | }
91 |
--------------------------------------------------------------------------------
/src/helpers.js:
--------------------------------------------------------------------------------
1 | import { isDraft, current } from 'immer';
2 | import {
3 | actionOnSymbol,
4 | actionSymbol,
5 | computedSymbol,
6 | effectOnSymbol,
7 | persistSymbol,
8 | reducerSymbol,
9 | thunkOnSymbol,
10 | thunkSymbol,
11 | } from './constants';
12 |
13 | export const debug = (state) => {
14 | if (isDraft(state)) {
15 | return current(state);
16 | }
17 | return state;
18 | };
19 |
20 | export const actionOn = (targetResolver, fn, config) => ({
21 | [actionOnSymbol]: true,
22 | fn,
23 | targetResolver,
24 | config,
25 | });
26 |
27 | export const action = (fn, config) => ({
28 | [actionSymbol]: true,
29 | fn,
30 | config,
31 | });
32 |
33 | const defaultStateResolvers = [(state) => state];
34 |
35 | export const computed = (fnOrStateResolvers, fn) => {
36 | if (typeof fn === 'function') {
37 | return {
38 | [computedSymbol]: true,
39 | fn,
40 | stateResolvers: fnOrStateResolvers,
41 | };
42 | }
43 | return {
44 | [computedSymbol]: true,
45 | fn: fnOrStateResolvers,
46 | stateResolvers: defaultStateResolvers,
47 | };
48 | };
49 |
50 | export function effectOn(dependencyResolvers, fn) {
51 | return {
52 | [effectOnSymbol]: true,
53 | dependencyResolvers,
54 | fn,
55 | };
56 | }
57 |
58 | export function generic(value) {
59 | return value;
60 | }
61 |
62 | export const persist = (model, config) =>
63 | // if we are not running in a browser context this becomes a no-op
64 | typeof window === 'undefined'
65 | ? model
66 | : {
67 | ...model,
68 | [persistSymbol]: config,
69 | };
70 |
71 | export const thunkOn = (targetResolver, fn) => ({
72 | [thunkOnSymbol]: true,
73 | fn,
74 | targetResolver,
75 | });
76 |
77 | export const thunk = (fn) => ({
78 | [thunkSymbol]: true,
79 | fn,
80 | });
81 |
82 | export const reducer = (fn, config) => ({
83 | [reducerSymbol]: true,
84 | fn,
85 | config,
86 | });
87 |
--------------------------------------------------------------------------------
/website/docs/docs/typescript-api/thunk-on.md:
--------------------------------------------------------------------------------
1 | # ThunkOn
2 |
3 | Defines a [thunkOn](/docs/api/thunk-on.html) listener against your model.
4 |
5 | ## API
6 |
7 | ```typescript
8 | ThunkOn<
9 | Model extends object = {},
10 | Injections = any,
11 | StoreModel extends object = {}
12 | >
13 | ```
14 |
15 | - `Model`
16 |
17 | The model against which the [thunkOn](/docs/api/thunk-on.html) is being defined. You need to provide this so that the actions that will be provided to your [thunkOn](/docs/api/thunk-on.html) are correctly typed.
18 |
19 | - `Injections`
20 |
21 | If your store was configured with injections, and you intend to use them within your [thunkOn](/docs/api/thunk-on.html), then you should provide the type of the injections here.
22 |
23 | - `StoreModel`
24 |
25 | If you plan on targeting an action from another part of your store state then you will need to provide your store model so that the provided store actions are correctly typed.
26 |
27 | Additionally, if you plan on using the `getStoreState` or `getStoreActions` APIs of a [thunkOn](/docs/api/thunk-on.html) then you will also need this so that their results are correctly typed.
28 |
29 |
30 | ## Example
31 |
32 | ```typescript
33 | import { ThunkOn, thunkOn, Action, action } from 'easy-peasy';
34 | import { StoreModel, Injections } from '../index';
35 |
36 | interface AuditModel {
37 | logs: string[];
38 | addLog: Action;
39 | onTodoAdded: ThunkOn;
40 | }
41 |
42 | const auditModel: AuditModel = {
43 | logs: [],
44 | addLog: action((state, payload) => {
45 | state.logs.push(payload);
46 | }),
47 | onTodoAdded: thunkOn(
48 | (actions, storeActions) => storeActions.todos.addTodo,
49 | async (actions, payload, { injections }) => {
50 | await injections.auditService.add(`Added todo: ${payload}`);
51 | actions.addLog(`Added todo: ${payload}`);
52 | }
53 | )
54 | }
55 | ```
--------------------------------------------------------------------------------
/examples/nextjs-ssr/store/store.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { createStore, action, persist } from 'easy-peasy';
3 |
4 | let store;
5 |
6 | const initialState = {
7 | counter: { count: 0 },
8 | shop: { basket: {} },
9 | inventory: { items: [] },
10 | };
11 |
12 | const counterModel = {
13 | ...initialState.counter,
14 | increment: action((state) => {
15 | state.count += 1;
16 | }),
17 | };
18 |
19 | const shopModel = {
20 | ...initialState.shop,
21 | addItem: action((state, id) => {
22 | if (state.basket[id]) {
23 | state.basket[id] += 1;
24 | } else {
25 | state.basket[id] = 1;
26 | }
27 | }),
28 | };
29 |
30 | const inventoryModel = {
31 | ...initialState.inventory,
32 | setItems: action((state, items) => {
33 | state.items = items;
34 | }),
35 | };
36 |
37 | const storeModel = {
38 | counter: counterModel,
39 | shop: shopModel,
40 | inventory: inventoryModel,
41 | };
42 |
43 | function initStore(preloadedState = initialState) {
44 | return createStore(persist(storeModel, { allow: ['shop'] }), {
45 | initialState: preloadedState,
46 | });
47 | }
48 |
49 | export const initializeStore = (preloadedState) => {
50 | let _store = store ?? initStore(preloadedState);
51 |
52 | // After navigating to a page with an initial Redux state, merge that state
53 | // with the current state in the store, and create a new store
54 | if (preloadedState && store) {
55 | _store = initStore({
56 | ...store.getState(),
57 | ...preloadedState,
58 | });
59 | // Reset the current store
60 | store = undefined;
61 | }
62 |
63 | // For SSG and SSR always create a new store
64 | if (typeof window === 'undefined') return _store;
65 | // Create the store once in the client
66 | if (!store) store = _store;
67 |
68 | return _store;
69 | };
70 |
71 | export function useStore(initialState) {
72 | const store = useMemo(() => initializeStore(initialState), [initialState]);
73 | return store;
74 | }
75 |
--------------------------------------------------------------------------------
/tests/typescript/effect-on.ts:
--------------------------------------------------------------------------------
1 | import { createStore, EffectOn, effectOn, Action, action } from 'easy-peasy';
2 |
3 | interface Injections {
4 | doSomething: () => Promise;
5 | }
6 |
7 | interface TodosModel {
8 | items: { id: number; text: string }[];
9 | foo: string;
10 | setFoo: Action;
11 | onStateChanged: EffectOn;
12 | onStateChangedAsync: EffectOn;
13 | }
14 |
15 | interface StoreModel {
16 | todos: TodosModel;
17 | rootData: number;
18 | }
19 |
20 | const todosModel: TodosModel = {
21 | items: [],
22 | foo: 'bar',
23 | setFoo: action((state, payload) => {
24 | state.foo = payload;
25 | }),
26 | onStateChanged: effectOn(
27 | [
28 | (state) => state.items,
29 | (state) => state.foo,
30 | (state, storeState) => storeState.rootData,
31 | ],
32 | (actions, change, helpers) => {
33 | actions.setFoo('bar');
34 |
35 | const [prevItems, prevFoo, prevRootData] = change.prev;
36 | prevItems[0].text;
37 | const baz = `${prevFoo}bar`;
38 | prevRootData + 2;
39 |
40 | const [currentItems, currentFoo, currentRootData] = change.current;
41 | const qux = `${currentFoo}bar`;
42 | currentItems[0].text;
43 | currentRootData + 2;
44 |
45 | helpers.injections.doSomething().then(() => {});
46 | helpers.dispatch.todos.setFoo('plop');
47 | helpers.getState().items[0].id + 2;
48 | helpers.getStoreActions().todos.setFoo('plop');
49 | helpers.getStoreState().rootData + 2;
50 | helpers.meta.parent[0].toLowerCase();
51 | helpers.meta.path[0].toLowerCase();
52 |
53 | return () => console.log('dispose');
54 | },
55 | ),
56 | onStateChangedAsync: effectOn(
57 | [(state) => state.items],
58 | // Should not generate error for async effect
59 | async (actions, change, helpers) => {
60 | return () => console.log('dispose');
61 | },
62 | ),
63 | };
64 |
--------------------------------------------------------------------------------
/tests/typescript/hooks.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Actions,
3 | Thunk,
4 | Action,
5 | Reducer,
6 | State,
7 | createTypedHooks,
8 | useStoreActions,
9 | useStoreDispatch,
10 | useStoreState,
11 | useStore,
12 | } from 'easy-peasy';
13 |
14 | interface Model {
15 | stateArray: Array;
16 | stateBoolean: boolean;
17 | stateDate: Date;
18 | stateNull: null;
19 | stateNumber: number;
20 | stateRegExp: RegExp;
21 | stateString: string;
22 | stateUndefined: undefined;
23 | stateUnion: string | null;
24 | actionImp: Action;
25 | actionNoPayload: Action;
26 | thunkImp: Thunk;
27 | reducerImp: Reducer;
28 | nested: {
29 | actionImp: Action;
30 | thunkImp: Thunk;
31 | };
32 | }
33 |
34 | let dispatch = useStoreDispatch();
35 | dispatch({ type: 'FOO' });
36 |
37 | let useStoreResult = useStoreState((state: State) => state.stateNumber);
38 | useStoreResult + 1;
39 | let useActionResult = useStoreActions(
40 | (actions: Actions) => actions.actionImp,
41 | );
42 | useActionResult(1);
43 |
44 | let store = useStore();
45 | `${store.getState().stateString}world`;
46 |
47 | const typedHooks = createTypedHooks();
48 |
49 | useStoreResult = typedHooks.useStoreState((state) => state.stateNumber);
50 | useStoreResult + 1;
51 | useActionResult = typedHooks.useStoreActions((actions) => actions.actionImp);
52 | useActionResult(1);
53 | dispatch = typedHooks.useStoreDispatch();
54 | dispatch({
55 | type: 'FOO',
56 | });
57 | store = typedHooks.useStore();
58 | `${store.getState().stateString}world`;
59 |
60 | const actionNoPayload = typedHooks.useStoreActions(
61 | (actions) => actions.actionNoPayload,
62 | );
63 | actionNoPayload();
64 |
65 | typedHooks.useStoreState(
66 | (state) => ({ num: state.stateNumber, str: state.stateString }),
67 | (prev, next) => {
68 | prev.num += 1;
69 | // @ts-expect-error
70 | prev.num += 'foo';
71 | return prev.num === next.num;
72 | },
73 | );
74 |
--------------------------------------------------------------------------------
/examples/react-native-todo/src/components/TodoList.tsx:
--------------------------------------------------------------------------------
1 | import React, {useRef, useState} from 'react';
2 | import {ScrollView, StyleSheet, Text, TextInput} from 'react-native';
3 | import {useStoreActions, useStoreState} from '../store';
4 | import Toolbar from './Toolbar';
5 |
6 | export function TodoList(): JSX.Element {
7 | const {remainingTodos, todos} = useStoreState(state => state);
8 | const [hideComplete, setHideComplete] = useState(false);
9 |
10 | const newTodoRef = useRef(null);
11 | const addTodo = useStoreActions(actions => actions.addTodo);
12 | const toggleTodo = useStoreActions(actions => actions.toggleTodo);
13 |
14 | return (
15 |
16 | {
19 | if (e.nativeEvent.text.trim() !== '') {
20 | addTodo({text: e.nativeEvent.text, done: false});
21 | newTodoRef.current?.clear();
22 | }
23 | }}
24 | returnKeyType="next"
25 | ref={newTodoRef}
26 | autoFocus={true}
27 | placeholderTextColor="#8fa5d1"
28 | style={[styles.todo, styles.newTodo]}
29 | />
30 |
31 | {todos &&
32 | (hideComplete ? remainingTodos : todos).map((todo, i) => (
33 | toggleTodo(todo.text)}>
37 | {todo.text}
38 |
39 | ))}
40 |
41 | );
42 | }
43 |
44 | const styles = StyleSheet.create({
45 | sectionContainer: {
46 | marginTop: 32,
47 | paddingHorizontal: 24,
48 | },
49 | todo: {
50 | fontSize: 24,
51 | marginBottom: 6,
52 | color: '#1E3A8A',
53 | },
54 | done: {
55 | textDecorationLine: 'line-through',
56 | },
57 | newTodo: {
58 | color: '#3E5EB9',
59 | fontStyle: 'italic',
60 | },
61 | });
62 |
--------------------------------------------------------------------------------
/examples/kanban/src/store/model.test.ts:
--------------------------------------------------------------------------------
1 | import { createStore } from 'easy-peasy';
2 | import { describe, expect, it } from 'vitest';
3 |
4 | import model from './model';
5 | import { Task } from './taskList.model';
6 |
7 | describe('store model', () => {
8 | it('should progress task from todo to doing', () => {
9 | // Arrange
10 | const store = createStore(model);
11 | const task: Task = { id: 'task-id', name: 'Task name' };
12 | store.getActions().todo.addTask(task);
13 |
14 | // Act
15 | store.getActions().todo.progressTask(task);
16 |
17 | // Assert
18 | const tasksInProgress = store.getState().doing.tasks;
19 | expect(tasksInProgress).toContain(task);
20 | });
21 |
22 | it('should progress task from doing to done', () => {
23 | // Arrange
24 | const store = createStore(model);
25 | const task: Task = { id: 'task-id', name: 'Task name' };
26 | store.getActions().doing.addTask(task);
27 |
28 | // Act
29 | store.getActions().doing.progressTask(task);
30 |
31 | // Assert
32 | const completedTasks = store.getState().done.tasks;
33 | expect(completedTasks).toContain(task);
34 | });
35 |
36 | it('should regress task from done to doing', () => {
37 | // Arrange
38 | const store = createStore(model);
39 | const task: Task = { id: 'task-id', name: 'Task name' };
40 | store.getActions().done.addTask(task);
41 |
42 | // Act
43 | store.getActions().done.regressTask(task);
44 |
45 | // Assert
46 | const tasksInProgress = store.getState().doing.tasks;
47 | expect(tasksInProgress).toContain(task);
48 | });
49 |
50 | it('should regress task from doing to todo', () => {
51 | // Arrange
52 | const store = createStore(model);
53 | const task: Task = { id: 'task-id', name: 'Task name' };
54 | store.getActions().doing.addTask(task);
55 |
56 | // Act
57 | store.getActions().doing.regressTask(task);
58 |
59 | // Assert
60 | const todoTasks = store.getState().todo.tasks;
61 | expect(todoTasks).toContain(task);
62 | });
63 | });
64 |
--------------------------------------------------------------------------------
/tests/actions.test.js:
--------------------------------------------------------------------------------
1 | import { action, createStore } from '../src';
2 |
3 | test('deprecated action API does nothing', () => {
4 | // ACT
5 | const store = createStore({
6 | count: 1,
7 | increment: (state) => {
8 | state.count += 1;
9 | },
10 | });
11 |
12 | // ASSERT
13 | expect(store.getActions().increment).toBeUndefined();
14 | });
15 |
16 | test('returning the state has no effect', () => {
17 | // ARRANGE
18 | const store = createStore({
19 | count: 1,
20 | doNothing: action((state) => state),
21 | });
22 | const prevState = store.getState();
23 |
24 | // ACT
25 | store.getActions().doNothing();
26 |
27 | // ASSERT
28 | expect(store.getState()).toBe(prevState);
29 | });
30 |
31 | describe('disabling immer via actions config', () => {
32 |
33 | test('not returning the state in action makes state undefined', () => {
34 | // ARRANGE
35 | const store = createStore({
36 | count: 1,
37 | addOne: action((state) => {
38 | state.count += 1;
39 | }, { immer: false }),
40 | });
41 |
42 | // ACT
43 | store.getActions().addOne();
44 |
45 | // ASSERT
46 | expect(store.getState()).toBeUndefined();
47 | });
48 |
49 | test('returning the state in action works', () => {
50 | // ARRANGE
51 | const store = createStore({
52 | count: 1,
53 | addOne: action((state) => {
54 | state.count += 1;
55 | return state;
56 | }, { immer: false }),
57 | });
58 |
59 | // ACT
60 | store.getActions().addOne();
61 |
62 | // ASSERT
63 | expect(store.getState()).toEqual({count: 2});
64 | });
65 |
66 | test('explicitly enabling immer in action works without returning state', () => {
67 | // ARRANGE
68 | const store = createStore({
69 | count: 1,
70 | addOne: action((state) => {
71 | state.count += 1;
72 | }, { immer: true }),
73 | });
74 |
75 | // ACT
76 | store.getActions().addOne();
77 |
78 | // ASSERT
79 | expect(store.getState()).toEqual({count: 2});
80 | });
81 |
82 | });
83 |
84 |
--------------------------------------------------------------------------------
/examples/react-native-todo/android/app/src/main/res/drawable/rn_edit_text_material.xml:
--------------------------------------------------------------------------------
1 |
2 |
16 |
21 |
22 |
23 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/examples/react-native-todo/android/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
13 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
14 |
15 | # When configured, Gradle will run in incubating parallel mode.
16 | # This option should only be used with decoupled projects. More details, visit
17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
18 | # org.gradle.parallel=true
19 |
20 | # AndroidX package structure to make it clearer which packages are bundled with the
21 | # Android operating system, and which are packaged with your app's APK
22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
23 | android.useAndroidX=true
24 | # Automatically convert third-party libraries to use AndroidX
25 | android.enableJetifier=true
26 |
27 | # Version of flipper SDK to use with React Native
28 | FLIPPER_VERSION=0.125.0
29 |
30 | # Use this property to specify which architecture you want to build.
31 | # You can also override it from the CLI using
32 | # ./gradlew -PreactNativeArchitectures=x86_64
33 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
34 |
35 | # Use this property to enable support to the new architecture.
36 | # This will allow you to use TurboModules and the Fabric render in
37 | # your application. You should enable this flag either if you want
38 | # to write custom TurboModules/Fabric components OR use libraries that
39 | # are providing them.
40 | newArchEnabled=false
41 |
42 | # Use this property to enable or disable the Hermes JS engine.
43 | # If set to false, you will be using JSC instead.
44 | hermesEnabled=true
45 |
--------------------------------------------------------------------------------