Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for Search Params #39

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
27 changes: 27 additions & 0 deletions examples/05_search_params/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "jotai-location-example",
"version": "0.1.0",
"private": true,
"dependencies": {
"@types/react": "latest",
"@types/react-dom": "latest",
"jotai": "latest",
"jotai-location": "latest",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"typescript": "latest"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}
8 changes: 8 additions & 0 deletions examples/05_search_params/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<head>
<title>jotai-location example</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
22 changes: 22 additions & 0 deletions examples/05_search_params/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { atomWithSearchParams } from 'jotai-location';
import { useAtom } from 'jotai/react';
import React from 'react';

const pageAtom = atomWithSearchParams('page', 1);

const Page = () => {
const [page, setPage] = useAtom(pageAtom);
return (
<div>
<div>Page {page}</div>
<button type="button" onClick={() => setPage((c) => c + 1)}>
+1
</button>
<p>See the url hash, change it there</p>
</div>
);
};

const App = () => <Page />;

export default App;
9 changes: 9 additions & 0 deletions examples/05_search_params/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react';
import { createRoot } from 'react-dom/client';

import App from './App';

const ele = document.getElementById('app');
if (ele) {
createRoot(ele).render(<App />);
}
151 changes: 151 additions & 0 deletions src/atomWithSearchParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { SetStateAction, WritableAtom } from 'jotai/vanilla';
import { atom } from 'jotai/vanilla';
import { atomWithLocation } from './atomWithLocation';

// Create an atom for managing location state, including search parameters.
const locationAtom = atomWithLocation();

/**
* Creates an atom that manages a single search parameter.
*
* The atom automatically infers the type of the search parameter based on the
* type of `defaultValue`.
*
* The atom's read function returns the current value of the search parameter.
* The atom's write function updates the search parameter in the URL.
*
* @param key - The key of the search parameter.
* @param defaultValue - The default value of the search parameter.
* @returns A writable atom that manages the search parameter.
*/
export const atomWithSearchParams = <T>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, then limit only to support string, number and boolean.

Suggested change
export const atomWithSearchParams = <T>(
export const atomWithSearchParams = <T extends string | number | boolean>(

and simply the code please?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried, but I can't figure out why this strange type is being returned in TypeScript. I haven't yet pinpointed the reason.

Atom:
const pageAtom = atomWithSearchParams("page", 1)

Expected:

const pageAtom: WritableAtom<number, [SetStateAction<number>], void>

Got:

const pageAtom: WritableAtom<1, [SetStateAction<1>], void>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in this case you need to do atomWithSearchParams<number>("page", 1) or atomWithSearchParams("page", 1 as number). Do you have any other issues with this change?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, union type doesn't work. We should overload the function.

key: string,
defaultValue: T,
): WritableAtom<T, [SetStateAction<T>], void> => {
/**
* Resolves the value of a search parameter based on the type of `defaultValue`.
*
* @param value - The raw value from the URL (could be `null` or `undefined`).
* @returns The resolved value matching the type of `defaultValue`.
*/
const resolveValue = (value: string | null | undefined): T => {
// If the value is null, undefined, or not a string, return the default value.
if (value === null || value === undefined || typeof value !== 'string') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a preference, but let's do this:

Suggested change
if (value === null || value === undefined || typeof value !== 'string') {
if (typeof value !== 'string') {

return defaultValue;
}

try {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need try?

// Determine the type of the default value and parse accordingly.
if (typeof defaultValue === 'number') {
if (value === '') {
console.warn(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can happen. So, at least it should be DEV-only warning.

`Empty string provided for key "${key}". Falling back to default value.`,
);
return defaultValue;
}

const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed as T;
}

console.warn(`Expected a number for key "${key}", got "${value}".`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for this.

return defaultValue;
}

// If the default value is a boolean, check if the value is `true` or `false`.
if (typeof defaultValue === 'boolean') {
if (value === 'true') return true as T;
if (value === 'false') return false as T;

console.warn(`Expected a boolean for key "${key}", got "${value}".`);
return defaultValue;
}

if (typeof defaultValue === 'string') {
return value as T;
}

// Handle object types (assume JSON if defaultValue is an object)
if (typeof defaultValue === 'object') {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === 'object') {
return parsed as T;
}
console.warn(`Expected an object for key "${key}", got "${value}".`);
return defaultValue;
}
} catch (err) {
console.error(`Failed to resolve value for key "${key}":`, err);
return defaultValue;
}
Comment on lines +69 to +81
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove handling object. It's not type safe.


// Fallback to default value for unsupported types
console.warn(`Unsupported defaultValue type for key "${key}".`);
return defaultValue;
Comment on lines +83 to +85
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's throw an error. We shouldn't reach here.

};

/**
* Converts the value into a string for use in the URL.
*
* Includes runtime type validation to ensure only compatible types are passed.
*
* @param value - The value to be serialized.
* @returns The stringified value.
*/
const parseValue = (value: T): string => {
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'string'
) {
return String(value);
}

if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch (err) {
console.error(`Failed to stringify object for key "${key}":`, err);
throw new Error(`Cannot serialize value for key "${key}".`);
}
}

console.warn(`Unsupported value type for key "${key}":`, typeof value);
throw new Error(`Unsupported value type for key "${key}".`);
};

return atom<T, [SetStateAction<T>], void>(
// Read function: Retrieves the current value of the search parameter.
(get) => {
const { searchParams } = get(locationAtom);

// Resolve the value using the parsing logic.
return resolveValue(searchParams?.get(key));
},
// Write function: Updates the search parameter in the URL.
(_, set, value) => {
set(locationAtom, (prev) => {
// Create a new instance of URLSearchParams to avoid mutating the original.
const newSearchParams = new URLSearchParams(prev.searchParams);

let nextValue;

if (typeof value === 'function') {
// If the new value is a function, compute it based on the current value.
const currentValue = resolveValue(newSearchParams.get(key));
nextValue = (value as (curr: T) => T)(currentValue);
} else {
// Otherwise, use the provided value directly.
nextValue = value;
}

// Update the search parameter with the computed value.
newSearchParams.set(key, parseValue(nextValue));

// Return the updated location state with new search parameters.
return { ...prev, searchParams: newSearchParams };
});
},
);
};
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { atomWithLocation } from './atomWithLocation';
export { atomWithHash } from './atomWithHash';
export { atomWithLocation } from './atomWithLocation';
export { atomWithSearchParams } from './atomWithSearchParams';