Skip to content

Commit fde9a68

Browse files
authored
✨ react: add useTag
1 parent d967daf commit fde9a68

File tree

4 files changed

+105
-1
lines changed

4 files changed

+105
-1
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,23 @@ 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+
875892
### `useTrait`
876893
877894
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.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { $internal, type Entity, type TagTrait, 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 useTag(
7+
target: Entity | World | undefined | null,
8+
tag: TagTrait
9+
): boolean | undefined {
10+
// Get the world from context.
11+
const contextWorld = useWorld();
12+
13+
// Memoize the target entity and a subscriber function.
14+
const memo = useMemo(
15+
() => (target ? createSubscriptions(target, tag, contextWorld) : undefined),
16+
[target, tag, contextWorld]
17+
);
18+
19+
// Initialize the state with whether the entity has the tag.
20+
const [value, setValue] = useState<boolean | undefined>(() => {
21+
return memo?.entity.has(tag) ?? false;
22+
});
23+
24+
// Subscribe to add/remove events for the tag.
25+
useEffect(() => {
26+
if (!memo) return;
27+
const unsubscribe = memo.subscribe(setValue);
28+
return () => unsubscribe();
29+
}, [memo]);
30+
31+
return value;
32+
}
33+
34+
function createSubscriptions(target: Entity | World, tag: TagTrait, contextWorld: World) {
35+
const world = isWorld(target) ? target : contextWorld;
36+
const entity = isWorld(target) ? target[$internal].worldEntity : target;
37+
38+
return {
39+
entity,
40+
subscribe: (setValue: (value: boolean | undefined) => void) => {
41+
const onAddUnsub = world.onAdd(tag, (e) => {
42+
if (e === entity) setValue(true);
43+
});
44+
45+
const onRemoveUnsub = world.onRemove(tag, (e) => {
46+
if (e === entity) setValue(false);
47+
});
48+
49+
setValue(entity.has(tag));
50+
51+
return () => {
52+
onAddUnsub();
53+
onRemoveUnsub();
54+
};
55+
},
56+
};
57+
}

packages/react/src/index.ts

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

packages/react/tests/trait.test.tsx

Lines changed: 30 additions & 1 deletion
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 { useTrait, useTraitEffect, WorldProvider } from '../src';
5+
import { useTag, useTrait, useTraitEffect, WorldProvider } from '../src';
66

77
declare global {
88
var IS_REACT_ACT_ENVIRONMENT: boolean;
@@ -13,6 +13,7 @@ global.IS_REACT_ACT_ENVIRONMENT = true;
1313

1414
let world: World;
1515
const Position = trait({ x: 0, y: 0 });
16+
const IsTagged = trait();
1617

1718
describe('useTrait', () => {
1819
beforeEach(() => {
@@ -48,6 +49,34 @@ describe('useTrait', () => {
4849
expect(position).toEqual({ x: 1, y: 1 });
4950
});
5051

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+
5180
it('reactively works with an entity at effect time', async () => {
5281
let entity: Entity | undefined;
5382
let position: TraitRecord<typeof Position> | undefined;

0 commit comments

Comments
 (0)