Skip to content

Commit

Permalink
feat: update shallow comparison logic, enhance error handling, upgrad…
Browse files Browse the repository at this point in the history
…e merge method, and increment version to 1.0.3
  • Loading branch information
samuelgja committed Nov 17, 2024
1 parent 0fd92df commit 0e7bc14
Show file tree
Hide file tree
Showing 21 changed files with 1,087 additions and 78 deletions.
113 changes: 99 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@

# Muya 🌀
Welcome to Muya - Making state management a breeze, focused on simplicity and scalability for real-world scenarios.

[![Build](https://github.com/samuelgja/muya/actions/workflows/build.yml/badge.svg)](https://github.com/samuelgja/muya/actions/workflows/build.yml)
[![Code quality Check](https://github.com/samuelgja/muya/actions/workflows/code-check.yml/badge.svg)](https://github.com/samuelgja/muya/actions/workflows/code-check.yml)
[![Build Size](https://img.shields.io/bundlephobia/minzip/muya?label=Bundle%20size)](https://bundlephobia.com/result?p=muya)



## 🚀 Features
- Easy State Creation: Kickstart your state management with simple and intuitive APIs.
- Selectors & Merges: Grab exactly what you need from your state and combine multiple states seamlessly.
Expand All @@ -16,7 +13,6 @@ Welcome to Muya - Making state management a breeze, focused on simplicity and sc
- TypeScript Ready: Fully typed for maximum developer sanity.
- Small Bundle Size: Lightweight and fast, no bloatware here.


## 📦 Installation

```bash
Expand Down Expand Up @@ -58,7 +54,6 @@ function App() {
}
```


### Selecting parts of the state globally
```tsx
import { create } from 'muya'
Expand All @@ -75,22 +70,21 @@ function App() {

```

### Merge two states
### Merge any states
```typescript
import { create, shallow } from 'muya'
import { create, shallow, merge } from 'muya'

const useName = create(() => 'John')
const useAge = create(() => 30)

const useFullName = useName.merge(useAge, (name, age) => ` ${name} and ${age}`, shallow)
const useFullName = merge([useName, useAge], (name, age) => `${name} and ${age}`)

function App() {
const fullName = useFullName()
return <div onClick={() => useName.setState((prev) => 'Jane')}>{fullName}</div>
}
```


### Promise based state and lifecycle management working with React Suspense
This methods are useful for handling async data fetching and lazy loading via React Suspense.

Expand Down Expand Up @@ -126,7 +120,6 @@ function Counter() {
}
```


## 🔍 API Reference

### `create`
Expand Down Expand Up @@ -155,14 +148,13 @@ const userAgeState = userState.select((user) => user.age);
```

### `merge`
Merges two states into a single state.
Merges any number states into a single state.
```typescript
const useName = create(() => 'John');
const useAge = create(() => 30);
const useFullName = useName.merge(useAge, (name, age) => ` ${name} and ${age}`);
const useFullName = merge([useName, useAge], (name, age) => `${name} and ${age}`);
```


### `setState`
Sets the state to a new value or a function that returns a new value.

Expand Down Expand Up @@ -204,6 +196,100 @@ const userState = create({ name: 'John', age: 30 });
const unsubscribe = userState.subscribe((state) => console.log(state));
```

### Promise Handling

#### Immediate Promise Resolution

```typescript
import { create } from 'muya';

// State will try to resolve the promise immediately, can hit the suspense boundary
const counterState = create(Promise.resolve(0));

function Counter() {
const counter = counterState();
return (
<div onClick={() => counterState.setState((prev) => prev + 1)}>
{counter}
</div>
);
}
```

#### Lazy Promise Resolution

```typescript
import { create } from 'muya';

// State will lazy resolve the promise on first access, this will hit the suspense boundary if the first access is from component and via `counterState.getState()` method
const counterState = create(() => Promise.resolve(0));

function Counter() {
const counter = counterState();
return (
<div onClick={() => counterState.setState((prev) => prev + 1)}>
{counter}
</div>
);
}
```

#### Promise Rejection Handling

```typescript
import { create } from 'muya';

// State will reject the promise
const counterState = create(Promise.reject('Error occurred'));

function Counter() {
try {
const counter = counterState();
return <div>{counter}</div>;
} catch (error) {
return <div>Error: {error}</div>;
}
}
```

#### Error Throwing

```typescript
import { create } from 'muya';

// State will throw an error
const counterState = create(() => {
throw new Error('Error occurred');
});

function Counter() {
try {
const counter = counterState();
return <div>{counter}</div>;
} catch (error) {
return <div>Error: {error.message}</div>;
}
}
```

#### Setting a state during promise resolution

```typescript
import { create } from 'muya';

// State will resolve the promise and set the state
const counterState = create(Promise.resolve(0));
// this will abort current promise and set the state to 10
counterState.setState(10);
function Counter() {
const counter = counterState();
return (
<div onClick={() => counterState.setState((prev) => prev + 1)}>
{counter}
</div>
);
}
```


### Access from outside the component
Expand All @@ -214,7 +300,6 @@ const user = userState.getState();
```
---


### Slicing new references
:warning: Slicing data with new references can lead to maximum call stack exceeded error.
It's recommended to not use new references for the state slices, if you need so, use `shallow` or other custom equality checks.
Expand Down
Binary file modified bun.lockb
Binary file not shown.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "muya",
"version": "1.0.2",
"version": "1.0.3",
"author": "[email protected]",
"description": "👀 Another React state management library",
"license": "MIT",
Expand Down Expand Up @@ -40,6 +40,7 @@
"@stylistic/eslint-plugin-jsx": "^2.9.0",
"@stylistic/eslint-plugin-ts": "^2.9.0",
"@testing-library/react": "^16.0.1",
"@testing-library/react-hooks": "^8.0.1",
"@types/bun": "^1.1.13",
"@types/eslint": "^9.6.0",
"@types/jest": "^29.5.12",
Expand Down
63 changes: 63 additions & 0 deletions src/__tests__/common.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { cancelablePromise, toType, useSyncExternalStore } from '../common'
import { renderHook } from '@testing-library/react-hooks'
import { createEmitter } from '../create-emitter'
import { longPromise } from './test-utils'

describe('toType', () => {
it('should cast object to specified type', () => {
const object = { a: 1 }
const result = toType<{ a: number }>(object)
expect(result.a).toBe(1)
})
})

describe('useSyncExternalStore', () => {
it('should return the initial state value', () => {
const emitter = createEmitter(() => 0)
const { result } = renderHook(() => useSyncExternalStore(emitter, (state) => state))
expect(result.current).toBe(0)
})

it('should update when the state value changes', () => {
let value = 0
const emitter = createEmitter(() => value)
const { result } = renderHook(() => useSyncExternalStore(emitter, (state) => state))

value = 1
emitter.emit()
expect(result.current).toBe(1)
})

it('should use the selector function', () => {
let value = 0
const emitter = createEmitter(() => ({ count: value }))
const { result } = renderHook(() => useSyncExternalStore(emitter, (state: { count: number }) => state.count))

value = 1
emitter.emit()
expect(result.current).toBe(1)
})

it('should use the isEqual function', () => {
let value = 0
const emitter = createEmitter(() => ({ count: value }))
const isEqual = jest.fn((a, b) => a === b)
const { result } = renderHook(() => useSyncExternalStore(emitter, (state: { count: number }) => state.count, isEqual))

value = 1
emitter.emit()
expect(result.current).toBe(1)
expect(isEqual).toHaveBeenCalled()
})

it('should test cancelable promise to abort', async () => {
const { promise, controller } = cancelablePromise(longPromise(1000 * 1000))
controller.abort()
expect(promise).rejects.toThrow('aborted')
})

it('should test cancelable promise to resolve', async () => {
const { promise } = cancelablePromise(longPromise(0))
expect(await promise).toBe(0)
})
})
84 changes: 84 additions & 0 deletions src/__tests__/create.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Suspense } from 'react'
import { create } from '../create'
import { renderHook, waitFor, act, render } from '@testing-library/react'
import { ErrorBoundary, longPromise } from './test-utils'

describe('create', () => {
it('should create test with base value', () => {
const state = create(0)
const result = renderHook(() => state())
expect(result.result.current).toBe(0)
})
it('should create test with function', () => {
const state = create(() => 0)
const result = renderHook(() => state())
expect(result.result.current).toBe(0)
})
it('should create test with promise', async () => {
const state = create(Promise.resolve(0))
const result = renderHook(() => state())
await waitFor(() => {
expect(result.result.current).toBe(0)
})
})
it('should create test with promise and wait to be resolved', async () => {
const state = create(longPromise)
const result = renderHook(() => state(), { wrapper: ({ children }) => <Suspense fallback={null}>{children}</Suspense> })

await waitFor(() => {
expect(result.result.current).toBe(0)
})
})
it('should create test with lazy promise and wait to be resolved', async () => {
const state = create(async () => await longPromise())
const result = renderHook(() => state(), { wrapper: ({ children }) => <Suspense fallback={null}>{children}</Suspense> })

await waitFor(() => {
expect(result.result.current).toBe(0)
})
})
it('should create test with promise and set value during the promise is pending', async () => {
const state = create(longPromise)
const result = renderHook(() => state(), { wrapper: ({ children }) => <Suspense fallback={null}>{children}</Suspense> })

act(() => {
state.setState(10)
})
await waitFor(() => {
expect(result.result.current).toBe(10)
})
})

it('should create test with lazy promise and set value during the promise is pending', async () => {
const state = create(async () => await longPromise())
const result = renderHook(() => state(), { wrapper: ({ children }) => <Suspense fallback={null}>{children}</Suspense> })

act(() => {
state.setState(10)
})
await waitFor(() => {
expect(result.result.current).toBe(10)
})
})

it('should fail inside the hook when the promise is rejected with not abort isWithError', async () => {
const state = create(Promise.reject('error-message'))

function Component() {
state()
return null
}

const result = render(
<ErrorBoundary fallback={<div>An error occurred.</div>}>
<Suspense fallback={'suspense-error'}>
<Component />
</Suspense>
</ErrorBoundary>,
)

await waitFor(() => {
expect(result.container.textContent).toBe('An error occurred.')
})
})
})
Loading

0 comments on commit 0e7bc14

Please sign in to comment.