Skip to content

Commit bcdb396

Browse files
authored
✨ react: add useHas (#177)
1 parent fde9a68 commit bcdb396

File tree

4 files changed

+160
-46
lines changed

4 files changed

+160
-46
lines changed

README.md

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -872,23 +872,6 @@ function App() {
872872
}
873873
```
874874
875-
### `useTag`
876-
877-
Observes an entity, or world, for a given tag and reactively updates when it is added or removed. Returns `true` when the tag is present or `false` when absent. Use this instead of `useTrait` for tag traits (traits with no data).
878-
879-
```js
880-
const IsActive = trait()
881-
882-
function ActiveIndicator({ entity }) {
883-
// Returns true if the entity has the tag, false otherwise
884-
const isActive = useTag(entity, IsActive)
885-
886-
if (!isActive) return null
887-
888-
return <div>🟢 Active</div>
889-
}
890-
```
891-
892875
### `useTrait`
893876
894877
Observes an entity, or world, for a given trait and reactively updates when it is added, removed or changes value. The returned trait snapshot maybe `undefined` if the trait is no longer on the target. This can be used to conditionally render.
@@ -930,6 +913,40 @@ return (
930913
)
931914
```
932915
916+
### `useTag`
917+
918+
Observes an entity, or world, for a tag and reactively updates when it is added or removed. Returns `true` when the tag is present or `false` when absent. Use this instead of `useTrait` for tags. For tracking the presence of non-tag traits, use `useHas`.
919+
920+
```js
921+
const IsActive = trait()
922+
923+
function ActiveIndicator({ entity }) {
924+
// Returns true if the entity has the tag, false otherwise
925+
const isActive = useTag(entity, IsActive)
926+
927+
if (!isActive) return null
928+
929+
return <div>🟢 Active</div>
930+
}
931+
```
932+
933+
### `useHas`
934+
935+
Observes an entity, or world, for any trait and reactively updates when it is added or removed. Returns `true` when the trait is present or `false` when absent. Unlike `useTrait`, this only tracks presence and not the trait's value.
936+
937+
```js
938+
const Health = trait({ amount: 100 })
939+
940+
function HealthIndicator({ entity }) {
941+
// Returns true if the entity has the trait, false otherwise
942+
const hasHealth = useHas(entity, Health)
943+
944+
if (!hasHealth) return null
945+
946+
return <div>❤️ Has Health</div>
947+
}
948+
```
949+
933950
### `useTraitEffect`
934951
935952
Subscribes a callback to a trait on an entity. This callback fires as an effect whenever it is added, removed or changes value without rerendering.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { $internal, Trait, type Entity, type World } from '@koota/core';
2+
import { useEffect, useMemo, useState } from 'react';
3+
import { isWorld } from '../utils/is-world';
4+
import { useWorld } from '../world/use-world';
5+
6+
export function useHas(target: Entity | World | undefined | null, trait: Trait): boolean | undefined {
7+
// Get the world from context.
8+
const contextWorld = useWorld();
9+
10+
// Memoize the target entity and a subscriber function.
11+
const memo = useMemo(
12+
() => (target ? createSubscriptions(target, trait, contextWorld) : undefined),
13+
[target, trait, contextWorld]
14+
);
15+
16+
// Initialize the state with whether the entity has the tag.
17+
const [value, setValue] = useState<boolean | undefined>(() => {
18+
return memo?.entity.has(trait) ?? false;
19+
});
20+
21+
// Subscribe to add/remove events for the tag.
22+
useEffect(() => {
23+
if (!memo) return;
24+
const unsubscribe = memo.subscribe(setValue);
25+
return () => unsubscribe();
26+
}, [memo]);
27+
28+
return value;
29+
}
30+
31+
function createSubscriptions(target: Entity | World, trait: Trait, contextWorld: World) {
32+
const world = isWorld(target) ? target : contextWorld;
33+
const entity = isWorld(target) ? target[$internal].worldEntity : target;
34+
35+
return {
36+
entity,
37+
subscribe: (setValue: (value: boolean | undefined) => void) => {
38+
const onAddUnsub = world.onAdd(trait, (e) => {
39+
if (e === entity) setValue(true);
40+
});
41+
42+
const onRemoveUnsub = world.onRemove(trait, (e) => {
43+
if (e === entity) setValue(false);
44+
});
45+
46+
setValue(entity.has(trait));
47+
48+
return () => {
49+
onAddUnsub();
50+
onRemoveUnsub();
51+
};
52+
},
53+
};
54+
}

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { useActions } from './hooks/use-actions';
22
export { useQuery } from './hooks/use-query';
33
export { useQueryFirst } from './hooks/use-query-first';
44
export { useTag } from './hooks/use-tag';
5+
export { useHas } from './hooks/use-has';
56
export { useTrait } from './hooks/use-trait';
67
export { useTraitEffect } from './hooks/use-trait-effect';
78
export { useWorld } from './world/use-world';

packages/react/tests/trait.test.tsx

Lines changed: 71 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createWorld, trait, universe, type Entity, type TraitRecord, type World
22
import { render } from '@testing-library/react';
33
import { act, StrictMode, useEffect, useState } from 'react';
44
import { beforeEach, describe, expect, it } from 'vitest';
5-
import { useTag, useTrait, useTraitEffect, WorldProvider } from '../src';
5+
import { useHas, useTag, useTrait, useTraitEffect, WorldProvider } from '../src';
66

77
declare global {
88
var IS_REACT_ACT_ENVIRONMENT: boolean;
@@ -49,34 +49,6 @@ describe('useTrait', () => {
4949
expect(position).toEqual({ x: 1, y: 1 });
5050
});
5151

52-
it('reactively returns the trait value for a tag trait', async () => {
53-
const entity = world.spawn(IsTagged);
54-
let isTagged: boolean | undefined;
55-
56-
function Test() {
57-
isTagged = useTag(entity, IsTagged);
58-
return null;
59-
}
60-
61-
await act(async () => {
62-
render(
63-
<StrictMode>
64-
<WorldProvider world={world}>
65-
<Test />
66-
</WorldProvider>
67-
</StrictMode>
68-
);
69-
});
70-
71-
expect(isTagged).toBe(true);
72-
73-
await act(async () => {
74-
entity.remove(IsTagged);
75-
});
76-
77-
expect(isTagged).toBe(false);
78-
});
79-
8052
it('reactively works with an entity at effect time', async () => {
8153
let entity: Entity | undefined;
8254
let position: TraitRecord<typeof Position> | undefined;
@@ -206,6 +178,76 @@ describe('useTrait', () => {
206178
});
207179
});
208180

181+
describe('useTag', () => {
182+
beforeEach(() => {
183+
universe.reset();
184+
world = createWorld();
185+
});
186+
187+
it('reactively returns a boolean for a trait', async () => {
188+
const entity = world.spawn(IsTagged);
189+
let isTagged: boolean | undefined;
190+
191+
function Test() {
192+
isTagged = useTag(entity, IsTagged);
193+
return null;
194+
}
195+
196+
await act(async () => {
197+
render(
198+
<StrictMode>
199+
<WorldProvider world={world}>
200+
<Test />
201+
</WorldProvider>
202+
</StrictMode>
203+
);
204+
});
205+
206+
expect(isTagged).toBe(true);
207+
208+
await act(async () => {
209+
entity.remove(IsTagged);
210+
});
211+
212+
expect(isTagged).toBe(false);
213+
});
214+
});
215+
216+
describe('useHas', () => {
217+
beforeEach(() => {
218+
universe.reset();
219+
world = createWorld();
220+
});
221+
222+
it('reactively returns a boolean for any trait', async () => {
223+
const entity = world.spawn(Position);
224+
let hasPosition: boolean | undefined;
225+
226+
function Test() {
227+
hasPosition = useHas(entity, Position);
228+
return null;
229+
}
230+
231+
await act(async () => {
232+
render(
233+
<StrictMode>
234+
<WorldProvider world={world}>
235+
<Test />
236+
</WorldProvider>
237+
</StrictMode>
238+
);
239+
});
240+
241+
expect(hasPosition).toBe(true);
242+
243+
await act(async () => {
244+
entity.remove(Position);
245+
});
246+
247+
expect(hasPosition).toBe(false);
248+
});
249+
});
250+
209251
describe('useTraitEffect', () => {
210252
beforeEach(() => {
211253
universe.reset();

0 commit comments

Comments
 (0)