Skip to content

Commit

Permalink
feat: encodeState and decodeState functions
Browse files Browse the repository at this point in the history
  • Loading branch information
asmyshlyaev177 committed Jul 2, 2024
1 parent e336827 commit abc4dcb
Show file tree
Hide file tree
Showing 10 changed files with 259 additions and 43 deletions.
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</p>

<div align="center">
Seamlessly sync React state with URL query parameters in Next.js/React.js applications. Simplify state management, enhance shareability, and maintain type safety—all through your browser's URL.
Seamlessly store complex React state object in URL query parameters without losing types. Works with Next.js/React.js applications.

<h4 align="center">Don't hesitate to open issue if you found a bug</h4>

Expand Down Expand Up @@ -43,9 +43,10 @@ Add a ⭐️ to support the project!

## Table of content
- [Installation](#installation)
- [Usage with Next.js](#useurlstate-hook-for-nextjs)
- [Usage with React.js](#useurlencode-hook-for-reactjs)
- [Low-level encode/decode functions](#encode-and-decode-helpers)
- [`useUrlState` for Next.js](#useurlstate-hook-for-nextjs)
- [`useUrlEncode` for React.js](#useurlencode-hook-for-reactjs)
- [`encodeState` and `decodeState` for pure JS usage](#encodestate-and-decodestate-helpers)
- [Low-level `encode` and `decode` functions](#encode-and-decode-helpers)
- [Gothas](#gothas)
- [Contact & Support](#contact--support)
- [Changelog](#changelog)
Expand All @@ -71,7 +72,7 @@ Go to [localhost:3000](http://localhost:3000)

## useUrlState hook for Next.js

`useUrlState` is a custom React hook for Next.js applications that manages state in the URL query string. It allows you to store and retrieve state from the URL search parameters, providing a way to persist state across page reloads and share application state via URLs.
`useUrlState` is a custom React hook for Next.js applications that make communication between client components easy. It allows you to store and retrieve state from the URL search parameters, providing a way to persist state across page reloads and share application state via URLs.


### Usage examples
Expand Down Expand Up @@ -178,7 +179,7 @@ function SettingsComponent() {

## useUrlEncode hook for React.js

`useUrlEncode` is a custom React hook that provides utility functions for encoding and decoding state to and from URL search parameters. This hook doesn't depend on Nextjs, and will works with any React application.
`useUrlEncode` is a custom React hook that provides utility functions for encoding and decoding state object to and from URL search parameters. This hook doesn't depend on Nextjs, and will works with any React application.

Accepts optional `defaultState` argument.

Expand All @@ -205,6 +206,22 @@ const Component = () => {
}
```

## `encodeState` and `decodeState` helpers

`encodeState` let you encode some object with optional `defaults`, and optional existing queryString
```ts
export const form = { name: '' };
...
encodeState({ name: 'test' }, form, 'someExistingParam=123');
```

`decodeState` let you decode queryString with optional defaults
```ts
export const form = { name: '' };
...
decodeState('name=Alex', form);
```

## `encode` and `decode` helpers

There low level helpers to stringify and parse query string params. Useful for other frameworks or pure JS.
Expand Down
2 changes: 2 additions & 0 deletions packages/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export {
encodeState,
decodeState,
encode,
decode,
type Type,
Expand Down
88 changes: 88 additions & 0 deletions packages/urlstate/encodeState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { encodeState, decodeState } from './encodeState';

describe('encodeState', () => {
it('should encode a simple state object', () => {
const state = { str: 'test', num: 123, bool: true };
const expected = 'str=test&num=%E2%88%93123&bool=%F0%9F%97%B5true';
expect(encodeState(state)).toEqual(expected);
});

it('should encode a nested state object', () => {
const state = {
str: 'test',
num: 123,
nested: { bool: true, arr: [1, 2, 3] },
};
const expected =
'str=test&num=%E2%88%93123&nested=%7B%27bool%27%3A%27%F0%9F%97%B5true%27%2C%27arr%27%3A%5B%27%E2%88%931%27%2C%27%E2%88%932%27%2C%27%E2%88%933%27%5D%7D';
expect(encodeState(state)).toEqual(expected);
});

it('should encode a state object with default values', () => {
const state = { str: 'test', num: 123, bool: true };
const defaults = { str: '', num: 0, bool: false };
const expected = 'str=test&num=%E2%88%93123&bool=%F0%9F%97%B5true';

expect(encodeState(state, defaults)).toEqual(expected);
expect(encodeState({ ...state, bool: false }, defaults)).toEqual(
'str=test&num=%E2%88%93123',
);
expect(encodeState({ ...state, bool: false, str: '' }, defaults)).toEqual(
'num=%E2%88%93123',
);
});

it('should preserve existing params', () => {
const state = { str: 'test', num: 123, bool: true };
const existing = new URLSearchParams('key1=value1&key2=value2');
const defaults = { str: '', num: 0, bool: false };

expect(encodeState(state, defaults, existing)).toEqual(
'key1=value1&key2=value2&str=test&num=%E2%88%93123&bool=%F0%9F%97%B5true',
);
expect(
encodeState(state, null as unknown as typeof state, existing),
).toEqual(
'key1=value1&key2=value2&str=test&num=%E2%88%93123&bool=%F0%9F%97%B5true',
);
expect(
encodeState(state, defaults, null as unknown as URLSearchParams),
).toEqual('str=test&num=%E2%88%93123&bool=%F0%9F%97%B5true');
});

it('should return an empty string for an empty state object', () => {
expect(encodeState({})).toEqual('');
expect(encodeState(undefined as unknown as object)).toEqual('');
expect(encodeState(null as unknown as object)).toEqual('');
});
});

describe('decodeState', () => {
it('should decode a simple state object', () => {
const uriString = 'key1=value1&key2=value2';
const expected = { key1: 'value1', key2: 'value2' };
expect(decodeState(uriString)).toEqual(expected);
});

it('should decode a state object with nested values', () => {
const uriString = 'key1=value1&key2={"nestedKey":"nestedValue"}';
const expected = { key1: 'value1', key2: { nestedKey: 'nestedValue' } };
expect(decodeState(uriString)).toEqual(expected);
});

it('should decode a state object with default values', () => {
const expected = { key1: 'value1', key2: 'value2' };
const defaults = { key1: '', key2: '' };
expect(decodeState('', defaults)).toEqual(defaults);
expect(decodeState('key1=value1', defaults)).toEqual({
...expected,
key2: defaults.key2,
});
});

it('should return an empty object for an empty URI string', () => {
expect(decodeState('')).toEqual({});
expect(decodeState(undefined as unknown as string)).toEqual({});
expect(decodeState(null as unknown as string)).toEqual({});
});
});
57 changes: 57 additions & 0 deletions packages/urlstate/encodeState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { encode, decode } from './encoder';
import { type JSONCompatible, getParams } from './utils';

/**
* Encodes the state object into a URL query string.
*
* @param {JSONCompatible<T>} state - The state object to encode.
* @param {JSONCompatible<T>} [defaults] - Optional default values for the state object.
* @return {string} The encoded URL query string representing the state object.
*
* * Example:
* ```ts
* export const form = { name: '' };
* encodeState({ name: 'test' }, form, 'someExistingParam=123');
* ```
*/
export function encodeState<T>(
state: never extends T ? object : JSONCompatible<T>,
defaults?: JSONCompatible<T>,
paramsToKeep?: string | URLSearchParams,
) {
const params = getParams(paramsToKeep);
Object.entries(state || {}).forEach(([key, value]) => {
const initialVal = defaults?.[key as keyof typeof defaults];
if (JSON.stringify(value) !== JSON.stringify(initialVal)) {
params.set(key, encode(value));
}
});
return params.toString();
}

/**
* Decodes a URI string into an object of type T.
*
* @param {string} uriString - The URI string to decode.
* @return {T} The decoded object of type T.
*
* * Example:
* ```ts
* export const form = { name: '' };
* decodeState('key=value&name=Alex', form);
* ```
*/
export function decodeState<T>(
uriString: string | URLSearchParams,
defaults?: never extends T ? object : JSONCompatible<T>,
) {
return {
...(defaults || {}),
...Object.fromEntries(
[...getParams(uriString).entries()].map(([key, value]) => [
key,
decode(value) ?? defaults?.[key as keyof typeof defaults],
]),
),
} as never extends T ? object : T;
}
11 changes: 10 additions & 1 deletion packages/urlstate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,14 @@ import { encode, decode } from './encoder';
import { type Type, type JSONCompatible, typeOf } from './utils';
import { useUrlEncode } from './useUrlEncode';
import { useUrlState } from './useUrlState';
export { encode, decode, typeOf, useUrlEncode, useUrlState };
import { encodeState, decodeState } from './encodeState';
export {
encode,
decode,
typeOf,
useUrlEncode,
useUrlState,
encodeState,
decodeState,
};
export type { Type, JSONCompatible };
1 change: 1 addition & 0 deletions packages/urlstate/useUrlEncode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('useUrlEncode', () => {
} = renderHook(() => useUrlEncode<object>());

expect(state).toStrictEqual(parse(stringify(state)));
expect(state).toStrictEqual(parse(new URLSearchParams(stringify(state))));
});
});

Expand Down
59 changes: 24 additions & 35 deletions packages/urlstate/useUrlEncode.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,48 @@
import React from 'react';
import { typeOf, type JSONCompatible, type DeepReadonly } from './utils';
import { encode, decode } from './encoder';
import { encodeState, decodeState } from './encodeState';

/**
* Returns stringify and parse functions
* A hook that returns stringify and parse functions for encoding and decoding state
* to and from URL search parameters.
*
* @param {?JSONCompatible<T>} [stateShape] Optional fallback values for state
* @template T - The type of the state shape
* @param {JSONCompatible<T>} [stateShape] Optional fallback values for state
* @returns {} `stringify` and `parse` functions
*
* * Example:
* ```ts
* export const form = { name: '' };
* const { parse, stringify } = useUrlEncode(form);
*
* stringify({ name: 'John' }, 'someExistingParamToKeep=123');
* // by default it's uses router.push with scroll: false
* parse('name=Tom');
* ```
*/
export function useUrlEncode<T>(stateShape?: JSONCompatible<T>) {
const stringify = React.useCallback(
function (
state:
| DeepReadonly<NonNullable<typeof stateShape>>
| NonNullable<typeof stateShape>,
strOrSearchParams?: string | URLSearchParams,
paramsToKeep?: string | URLSearchParams,
): string {
const params = getParams(strOrSearchParams);
if (typeOf(state) === 'object') {
Object.entries(state).forEach(([key, value]) => {
const initialVal = stateShape?.[key as keyof typeof stateShape];
if (JSON.stringify(value) !== JSON.stringify(initialVal)) {
params.set(key, encode(value));
} else {
params.delete(key);
}
});
}

return params.toString();
return typeOf(state) === 'object'
? encodeState(state, stateShape, paramsToKeep)
: '';
},
[stateShape],
);

const parse = React.useCallback(
function (strOrSearchParams?: string | URLSearchParams) {
const params = getParams(strOrSearchParams);
return {
...stateShape,
...Object.fromEntries(
[...params.entries()].map(([key, value]) => [
key,
decode(value) ?? stateShape?.[key as keyof typeof stateShape],
]),
),
} as DeepReadonly<NonNullable<typeof stateShape>>;
function (strOrSearchParams: string | URLSearchParams) {
return decodeState(strOrSearchParams, stateShape) as DeepReadonly<
NonNullable<typeof stateShape>
>;
},
[stateShape],
);

return { parse, stringify };
}

const getParams = (strOrSearchParams?: string | URLSearchParams) =>
new URLSearchParams(
typeof strOrSearchParams === 'string'
? strOrSearchParams
: strOrSearchParams?.toString?.() || '',
);
24 changes: 24 additions & 0 deletions packages/urlstate/useUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ import { type JSONCompatible } from './utils';
* NextJS hook. Returns `state`, `updateState`, and `updateUrl` functions
*
* @param {?JSONCompatible<T>} [defaultState] Optional fallback values for state
*
* * Example:
* ```ts
* export const form = { name: '' };
* const { state, updateState, updateUrl } = useUrlState(form);
*
* updateState({ name: 'test' });
* // by default it's uses router.push with scroll: false
* updateUrl({ name: 'test' }, { replace: true, scroll: true });
* ```
* * Auto update URL
* ```ts
* const timer = React.useRef(0 as unknown as NodeJS.Timeout);
* React.useEffect(() => {
* clearTimeout(timer.current);
* timer.current = setTimeout(() => {
* updateUrl(state);
* }, 500);
*
* return () => {
* clearTimeout(timer.current);
* };
* }, [state, updateUrl]);
* ```
*/
export function useUrlState<T>(defaultState?: JSONCompatible<T>) {
const router = useRouter();
Expand Down
24 changes: 23 additions & 1 deletion packages/urlstate/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { typeOf } from './utils';
import { typeOf, getParams } from './utils';

describe('typeOf', () => {
it('string', () => {
Expand Down Expand Up @@ -50,3 +50,25 @@ describe('typeOf', () => {
expect(typeOf(() => {})).toEqual('function');
});
});

describe('getParams', () => {
it('should return URLSearchParams instance', () => {
expect(getParams('')).toBeInstanceOf(URLSearchParams);
});

it('from string', () => {
const url = 'key1=value1&key2=value2';
const url2 = `?${url}`;
expect(getParams(url).toString()).toStrictEqual(url);
expect(getParams(url2).toString()).toStrictEqual(url);
});

it('from URLSearchParams', () => {
const url = 'key1=value1&key2=value2';
const url2 = `?${url}`;
const params = new URLSearchParams(url);
const params2 = new URLSearchParams(url2);
expect(getParams(params).toString()).toStrictEqual(url);
expect(getParams(params2).toString()).toStrictEqual(url);
});
});
7 changes: 7 additions & 0 deletions packages/urlstate/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,10 @@ export type JSONCompatible<T> = unknown extends T
export type DeepReadonly<T> = Readonly<{
readonly [P in keyof T]: DeepReadonly<T[P]>;
}>;

export const getParams = (strOrSearchParams?: string | URLSearchParams) =>
new URLSearchParams(
typeof strOrSearchParams === 'string'
? strOrSearchParams
: strOrSearchParams?.toString?.() || '',
);

0 comments on commit abc4dcb

Please sign in to comment.