Converting Vue's reactives to atoms #16
Replies: 5 comments
-
Hey there! Can you show in which case this is needed? If possible, show an example of pseudo-code. |
Beta Was this translation helpful? Give feedback.
-
I want to outsource computation to nanostores, but Vue reactives are getting in the way. const $collection = atom([])
const slice = ($id: Atom) => computed([$collection, $id], (col, id) => {
// return something
}) How can construct a store that will be reactive to Vue prop/ref changes, and would make Vue react to store changes? I could off course compute from store values, but I'd like to keep state slicing logic outside of components. |
Beta Was this translation helpful? Give feedback.
-
I have to do this now: import { toValue, watch, WatchSource } from 'vue';
import { atom } from 'nanostores';
export const toAtom = <T>(source: WatchSource<T>) => {
const target = atom<T>(toValue(source));
watch(source, (newValue) => {
target.set(newValue);
});
return target;
}; |
Beta Was this translation helpful? Give feedback.
-
Ultimately, Vue is somewhat terrible at getting a dynamic slice of state/store to be reactive. I'd rather manage computation at nanostores' level - I have more control, and I don't have to deal with all the unpredictability that comes from using Vue's proxy impplementation outside of components. I have endep up not using export const useSelect = <T>(selectorFn: () => Atom<T>) => {
// Vue computed
return computed(() => useStore(selectorFn()).value);
}; export const $books = computed($booksQuery, (q) => q.data || []);
export const selectBooks = () => $books;
export const selectBookById = (id: string) => {
return computed($books, (books) => books.find((book) => book.id === id));
};
export const selectBooksById = (ids: string[]) => {
return computed($books, (books) => ids.map((id) => books.find((book) => book.id === id)).filter(Boolean));
}; The way you would use it is by: const props = defineProps<{
bookIds: string[]
}>()
const books = useSelect(() => selectBooksById(props.bookIds)); |
Beta Was this translation helpful? Give feedback.
-
After a few more iterations, this is where this stands. Perhaps you can find some inspiration for the future: import { customRef, getCurrentScope, onScopeDispose, Ref, watch } from 'vue';
import { Store } from 'nanostores';
import isDeepEqual from 'fast-deep-equal';
const isEqual = (a: unknown, b: unknown) => {
if (Array.isArray(a) || Array.isArray(b)) {
// avoid deep comparison of array members
// should be more efficient to do the comparison at computed element level
return false;
}
return isDeepEqual(a, b);
};
// Allows us to rebuild the store in case there are reactive Vue dependencies in the factory
export const useSelect = <T>(selectorFn: () => Store<T>, equalityFn = isEqual): Ref<T> => {
return customRef((track, trigger) => {
let unsubscribe: () => void = () => {};
let lastValue: T;
watch(
selectorFn,
(store) => {
unsubscribe();
unsubscribe = store.subscribe((newValue) => {
if (!equalityFn(lastValue, newValue)) {
lastValue = newValue;
trigger();
}
});
},
{
immediate: true,
}
);
if (getCurrentScope()) {
onScopeDispose(() => {
unsubscribe();
});
}
return {
get() {
track();
return lastValue;
},
set() {
throw new Error('Selections are immutable');
},
};
});
}; import { describe, expect } from 'vitest';
import { atom, computed, type Store } from 'nanostores';
import { defineComponent, nextTick, type PropType, type VNode } from 'vue';
import { useSelect } from '@/services/useSelect';
import { render } from '@testing-library/vue';
const Component = defineComponent({
props: {
selector: {
type: Function as PropType<(...args: any[]) => Store>,
required: true,
},
deps: {
type: Array as PropType<any[]>,
},
formatter: {
type: Function as PropType<(value: any) => VNode>,
},
},
setup(props) {
const ref = useSelect(() => props.selector(...(props.deps || [])));
return () => <div>{props.formatter ? props.formatter(ref.value) : ref.value}</div>;
},
});
const Wrapper = defineComponent({
setup(_, { slots }) {
return () => slots.default && slots.default();
},
});
describe('useSelect', () => {
it('selects the value from root store', () => {
const $store = atom('foo');
const selector = () => $store;
const screen = render(<Component selector={selector} />);
expect(screen.getByText('foo')).toBeInTheDocument();
});
it('creates a single subscription for root store', async () => {
const $store = atom('foo');
const selector = () => $store;
const screen = render(<Component selector={selector} />);
expect(screen.getByText('foo')).toBeInTheDocument();
expect($store.lc).toBe(1);
$store.set('bar');
await nextTick();
expect(screen.getByText('bar')).toBeInTheDocument();
expect($store.lc).toBe(1);
});
it('selects from multiple target components', async () => {
const $store = atom(['a', 'b']);
const selectorA = () => computed($store, (val) => val.at(0));
const selectorB = () => computed($store, (val) => val.at(1));
const screen = render(
<Wrapper>
<Component selector={selectorA} />
<Component selector={selectorB} />
</Wrapper>
);
expect(screen.getByText('a')).toBeInTheDocument();
expect(screen.getByText('b')).toBeInTheDocument();
expect($store.lc).toBe(2);
$store.set(['c', 'd']);
await nextTick();
expect(screen.getByText('c')).toBeInTheDocument();
expect(screen.getByText('d')).toBeInTheDocument();
expect($store.lc).toBe(2);
});
it('does not leak memory after component unmounts', async () => {
const $store = atom('foo');
const selector = () => $store;
const screen = render(<Component selector={selector} />);
expect(screen.getByText('foo')).toBeInTheDocument();
expect($store.lc).toBe(1);
screen.unmount();
expect($store.lc).toBe(0);
});
it('creates a new slice when reactive Vue dependency is updated', async () => {
type User = { id: number; name: string };
const $store = atom<User[]>([
{ id: 2, name: 'John' },
{ id: 3, name: 'Jane' },
]);
const selector = (id: number) => computed($store, (values) => values.find((el) => el.id === id));
const deps = [2];
const formatter = (value: User) => <>{value.name}</>;
const screen = render(<Component selector={selector} deps={deps} formatter={formatter} />);
expect(screen.getByText('John')).toBeInTheDocument();
expect($store.lc).toBe(1);
await screen.rerender({
selector,
formatter,
deps: [3],
});
expect(screen.getByText('Jane')).toBeInTheDocument();
// @todo: revisit this when there is a response to
// https://github.com/nanostores/nanostores/pull/273
// expect($store.lc).toBe(1);
});
it('updates component when store is populated with a matching computed value', async () => {
type User = { id: number; name: string };
const $store = atom<User[]>([
{ id: 2, name: 'John' },
{ id: 3, name: 'Jane' },
]);
const selector = (id: number) => computed($store, (values) => values.find((el) => el.id === id));
const deps = [4];
const formatter = (value?: User) => <>{value?.name || 'Unknown'}</>;
const screen = render(<Component selector={selector} deps={deps} formatter={formatter} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
expect($store.lc).toBe(1);
$store.set([...$store.get(), { id: 4, name: 'Joanna' }]);
await nextTick();
expect(screen.getByText('Joanna')).toBeInTheDocument();
expect($store.lc).toBe(1);
});
}); |
Beta Was this translation helpful? Give feedback.
-
It would be nice to have a consistent way of converting Vue's complicated ref, reactive, computed and what not to a streamlined atom.
Beta Was this translation helpful? Give feedback.
All reactions