Skip to content
/ lys Public

A Vuex like micro state manager, which can separate procedure from Component, and make us happy.

License

Notifications You must be signed in to change notification settings

hanakla/lys

Repository files navigation

CI latest BundleSize License npm

Lys

Lys (risu) is an minimal statement manger for '21s React.

It's focus to Per page state management, not application global state management.
Lys is usable to instead of useReducer, Mobx, or Recoil if you have async procedure.

See CodeSandbox Example

yarn add @fleur/lys

Features

  • Per page level micro state management
  • Initial state via external data
    • Can be use with likes Next.js, useSWR
  • Testing friendly
  • Type safe
  • Minimal re-rendering

Usage

Summary in CodeSandbox Example.

First, define your slice.

import { createSlice } from '@fleur/lys';

const formSlice = createSlice({
  actions: {
    // Define actions
    async patchItem({ commit }, index: number, patch: Partial<State['form']['items'][0]>) {
      commit((draft) => {
        Object.assign(draft.form.items[index], patch);
      });
    },
    async submit({ state, commit }) {
      if (state.hasError) return;

      commit({ submitting: true });

      commit({
        submiting: false,
        form: await (
          await fetch('/api/users', { body: JSON.stringify(state.form) })
        ).json(),
      });
    },
    async validate({ state }) {
      commit({ hasError: false });

      // Use your favorite validator
      commit({ hasError: await validateForm(state.form) });
    },
  },
  computed: {
    // You can define computable values in `computed`
    // `computed` is cached between to next state changed
    itemOf: (state) => (index: number) => state.form.items[index],
    canSubmit: (state) => !state.submitting,
  },
  }, (): State => ({
    // Define initial state
    submitting: false,
    hasError: false,
    form: {
      id: null,
      username: "",
      items: [{ name: "" }],
    },
  })
);

Next, initialize slice on your page component

import { useLysSliceRoot, useLysSlice } from '@fleur/lys';

export const NewUserPage = () => {
  const { data: initialData, error } = useSWR('/users/1', fetcher);

  // Initialize slice by `useLysSliceRoot`
  // `initialState` in second argument, it shallow override to Slice's initial state.
  // `initialData` is re-evaluated when it changes from null or undefined to something else.
  //
  // Or can you define `fetchUser` in slice and call it in `useEffect()`
  const [state, actions] = useLysSliceRoot(
    formSlice,
    initialData ? { form: initialData } : null
  );

  const handleChangeName = useCallback(({ currentTarget }) => {
    // `set` is builtin action
    actions.set((draft) => {
      draft.form.username = currentTarget.value;
    });
  }, []);

  const handleSubmit = useCallback(async () => {
    await actions.validate();
    await actions.submit();
  }, []);

  return (
    <div>
      <label>
        Display name:
        <input type="text" value={state.form.name} onChange={handleChangeName} />
      </label>

      <h1>Your items</h1>
      {state.form.items.map((index) => (
        <Item index={index} />
      ))}

      <button disabled={!state.canSubmit} onClick={handleSubmit}>
        Register
      </button>
    </div>
  );
};

Use initialize slice into child component

// In child component
const Item = ({ index }) => {
  // Use slice from root component by `useLysSlice`
  const [state, actions] = useLysSlice(formSlice);
  const item = state.itemOf(index);

  const handleChangeName = useCallback(({ currentTarget }) => {
    // Can call action from child component and share state with root.
    // Re-rendering from root (no duplicate re-rendering)
    actions.patchItem(index, { name: currentTarget.value });
  }, []);

  return (
    <div>
      Item of #{index + 1}
      <label>
        Name: <input type="text" value={item.name} />
      </label>
    </div>
  );
};

Testing

Lys's Slice is very testable. Let look testing example!

import { instantiateSlice, createSlice } from "@fleur/lys";

// Define (Normally, import from other file)
const slice = createSlice(
  {
    actions: {
      increment({ commit }) {
        commit((draft) => draft.count++);
      },
    },
    computed: {
      isZero: (state) => state.count === 0,
    },
  },
  () => ({ count: 0, submitting: false })
);

describe("Testing slice", () => {
  it("Should increment one", async () => {
    // instantiate
    const { state, actions } = instantiateSlice(slice);

    // Expection
    expect(state.current.count).toBe(0);
    expect(state.current.isZero).toBe(true);

    await actions.increment();
    expect(state.current.count).toBe(1);
    expect(state.current.isZero).toBe(false);
  });

  it("mock slice actions (for component testing)", () => {
    const actionSpy = jest.fn(({ state }) => (state.count = 10));

    const { state, actions } = mockSlice(
      slice,
      {
        /* part of initial state here */
      },
      {
        /* Mock action implementations here */
        increment: actionSpy,
      }
    );

    actions.increment();
    expect(actionSpy).toBeCalled();
  });
});

About

A Vuex like micro state manager, which can separate procedure from Component, and make us happy.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published