diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab61388..ef2cca5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- `useDetectedEntities(text, options?)` hook — reactive, debounced detection of a
+ changing string (as-you-type). Returns `{ entities, isDetecting, status, error }`,
+ is cancellation-safe (latest text wins), and manages model readiness internally.
- `useDataDetector` hook — tracks model readiness (`status`/`isReady`/`error`),
exposes `detect`, and auto-downloads the model on Android (opt out with
`autoPrepare: false`).
diff --git a/README.md b/README.md
index 89609fa..c8904c5 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Cross-platform text data detection for React Native. Uses **NSDataDetector** on
- **Addresses** — Detect street addresses with parsed components (iOS)
- **Dates** — Detect dates and times with ISO 8601 output
- **Native accuracy** — Uses battle-tested platform APIs instead of regex
-- **React hook** — [`useDataDetector`](#usedatadetectoroptions) tracks model readiness and auto-downloads on Android
+- **React hooks** — `useDataDetector` (imperative) and `useDetectedEntities` (reactive, as-you-type); both track model readiness and auto-download on Android
- **Multiple languages** — Choose from 15 ML Kit language models on Android
- **Expo Modules API** — Built with the modern Expo native module system
@@ -65,10 +65,17 @@ const phones = await detect('Call 555-1234 or visit https://example.com', {
const fr = await detect('Appelez-moi au 01 23 45 67 89', { language: 'fr' });
```
-### Hook
+### Hooks
-The `useDataDetector` hook tracks model readiness and, on Android, downloads the
-model automatically on mount. On iOS the model is always ready.
+There are two hooks for two situations:
+
+- **`useDataDetector`** — *imperative*. Tracks model readiness and hands you a
+ `detect` function you call yourself (e.g. once per chat message).
+- **`useDetectedEntities`** — *reactive*. Pass it a (changing) string and it returns
+ the detected entities, debounced and recomputed as the text changes — ideal for
+ as-you-type input.
+
+Both download the model automatically on Android and are no-ops on iOS (always ready).
```tsx
import { useDataDetector } from 'react-native-data-detector';
@@ -87,6 +94,22 @@ function MyComponent() {
}
```
+```tsx
+import { useDetectedEntities } from 'react-native-data-detector';
+
+function LiveInput() {
+ const [text, setText] = useState('');
+ const { entities, isDetecting } = useDetectedEntities(text, { debounceMs: 250 });
+
+ return (
+ <>
+
+ {entities.length} detected{isDetecting ? '…' : ''}
+ >
+ );
+}
+```
+
## API
### `prepareModel(options?)`
@@ -145,6 +168,30 @@ React hook that tracks model availability and, on Android, downloads the languag
| `isReady` | `boolean` | `true` when `status === 'ready'`. |
| `error` | `Error \| null` | The last preparation error, or `null`. |
+### `useDetectedEntities(text, options?)`
+
+Reactive hook: pass a (changing) string and get back the detected entities, recomputed as the text changes. Debounced and cancellation-safe (the latest text wins), so it suits as-you-type input. Manages model readiness internally.
+
+**Parameters:**
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `text` | `string` | — | The text to detect entities in. |
+| `options.debounceMs` | `number` | `300` | Debounce applied to `text` before detecting. |
+| `options.types` | `DetectionType[]` | All types | Which entity types to detect. |
+| `options.language` | `ModelLanguage` | `'en'` | Which language model to use (Android only). |
+| `options.enabled` | `boolean` | `true` | When `false`, detection pauses and the last result is kept. |
+| `options.autoPrepare` | `boolean` | `true` | Download the model on mount if needed (Android). |
+
+**Returns:** `UseDetectedEntitiesResult`
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `entities` | `DetectedEntity[]` | Entities detected in the debounced `text`. |
+| `isDetecting` | `boolean` | `true` while a detection for the latest text is in flight. |
+| `status` | `ModelStatus` | Current model download state. |
+| `error` | `Error \| null` | The last detection or model error, or `null`. |
+
### `downloadModel()`
> **Deprecated since 0.3.0** — use [`prepareModel()`](#preparemodeloptions) instead. Kept as an alias (always targets the default `'en'` model) and will be removed in a future major version.
diff --git a/example/App.tsx b/example/App.tsx
index d53c981..4152289 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -1,377 +1,11 @@
-import { StatusBar } from 'expo-status-bar';
-import { useState } from 'react';
-import {
- ActivityIndicator,
- Platform,
- Pressable,
- SafeAreaView,
- ScrollView,
- StyleSheet,
- Text,
- TextInput,
- View,
-} from 'react-native';
-import {
- useDataDetector,
- type DetectedEntity,
- type DetectionType,
- type ModelLanguage,
-} from 'react-native-data-detector';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
-const SAMPLE_TEXT =
- 'Call me at (555) 123-4567 or email john@example.com.\n' +
- 'Visit https://reactnative.dev for docs.\n' +
- 'Meet me at 1 Infinite Loop, Cupertino, CA 95014 on March 15, 2025.';
-
-const ALL_TYPES: DetectionType[] = ['phoneNumber', 'link', 'email', 'address', 'date'];
-
-const TYPE_COLORS: Record = {
- phoneNumber: '#5856D6',
- link: '#007AFF',
- email: '#34C759',
- address: '#FF9500',
- date: '#FF2D55',
-};
-
-const TYPE_LABELS: Record = {
- phoneNumber: 'Phone',
- link: 'Link',
- email: 'Email',
- address: 'Address',
- date: 'Date',
-};
-
-const LANGUAGES: ModelLanguage[] = ['en', 'fr', 'es', 'de', 'ja', 'zh'];
-
-const STATUS_LABELS: Record = {
- notDownloaded: 'Model not downloaded',
- downloading: 'Downloading model…',
- ready: 'Model ready',
- error: 'Model error',
-};
+import DataDetector from './screens/DataDetector';
export default function App() {
- const [text, setText] = useState(SAMPLE_TEXT);
- const [selectedTypes, setSelectedTypes] = useState>(new Set(ALL_TYPES));
- const [results, setResults] = useState([]);
- const [loading, setLoading] = useState(false);
- const [detectError, setDetectError] = useState(null);
- const [language, setLanguage] = useState('en');
-
- // The hook tracks model readiness and auto-downloads on Android.
- const { detect, status, isReady, prepare, error } = useDataDetector({ language });
-
- const toggleType = (type: DetectionType) => {
- setSelectedTypes((prev) => {
- const next = new Set(prev);
- if (next.has(type)) {
- next.delete(type);
- } else {
- next.add(type);
- }
- return next;
- });
- };
-
- const handleDetect = async () => {
- if (!text.trim()) return;
- setLoading(true);
- setDetectError(null);
- try {
- const types = Array.from(selectedTypes);
- const entities = await detect(text, types.length < ALL_TYPES.length ? { types } : undefined);
- setResults(entities);
- } catch (e: any) {
- setResults([]);
- setDetectError(`Detection error: ${e.message}`);
- } finally {
- setLoading(false);
- }
- };
-
return (
-
-
-
- Data Detector
-
- {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'}
-
-
- {Platform.OS === 'android' && (
-
- Language Model
-
- {LANGUAGES.map((lang) => {
- const active = language === lang;
- return (
- setLanguage(lang)}
- >
-
- {lang.toUpperCase()}
-
-
- );
- })}
-
-
- {status === 'downloading' && }
-
- {error ? `Error: ${error.message}` : (STATUS_LABELS[status] ?? status)}
-
-
- {status === 'error' && (
- prepare().catch(() => {})}>
- Retry Download
-
- )}
-
- )}
-
-
- Input Text
-
-
-
-
- Entity Types
-
- {ALL_TYPES.map((type) => {
- const active = selectedTypes.has(type);
- return (
- toggleType(type)}
- >
-
- {TYPE_LABELS[type]}
-
-
- );
- })}
-
-
-
-
- {loading ? (
-
- ) : (
-
- {isReady ? 'Detect Entities' : 'Preparing model…'}
-
- )}
-
-
- {detectError && {detectError}}
-
- {results.length > 0 && (
-
-
- Results ({results.length} {results.length === 1 ? 'entity' : 'entities'})
-
- {results.map((entity, index) => (
-
-
-
- {TYPE_LABELS[entity.type]}
-
-
- [{entity.start}–{entity.end}]
-
-
- "{entity.text}"
- {entity.data && Object.keys(entity.data).length > 0 && (
-
- {Object.entries(entity.data).map(([key, value]) => (
-
- {key}: {value}
-
- ))}
-
- )}
-
- ))}
-
- )}
-
-
+
+
+
);
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: '#F2F2F7',
- },
- content: {
- padding: 20,
- paddingTop: Platform.OS === 'android' ? 48 : 20,
- },
- title: {
- fontSize: 28,
- fontWeight: '700',
- color: '#000',
- },
- subtitle: {
- fontSize: 14,
- color: '#8E8E93',
- marginBottom: 20,
- },
- section: {
- marginBottom: 20,
- },
- label: {
- fontSize: 13,
- fontWeight: '600',
- color: '#8E8E93',
- textTransform: 'uppercase',
- marginBottom: 8,
- },
- textInput: {
- backgroundColor: '#fff',
- borderRadius: 12,
- padding: 14,
- fontSize: 15,
- minHeight: 100,
- textAlignVertical: 'top',
- color: '#000',
- },
- chips: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 8,
- },
- chip: {
- paddingHorizontal: 14,
- paddingVertical: 7,
- borderRadius: 20,
- borderWidth: 1.5,
- backgroundColor: '#fff',
- },
- chipText: {
- fontSize: 14,
- fontWeight: '500',
- color: '#333',
- },
- chipTextActive: {
- color: '#fff',
- },
- detectButton: {
- backgroundColor: '#007AFF',
- borderRadius: 12,
- paddingVertical: 14,
- alignItems: 'center',
- marginBottom: 20,
- },
- detectButtonDisabled: {
- opacity: 0.6,
- },
- detectButtonText: {
- color: '#fff',
- fontSize: 17,
- fontWeight: '600',
- },
- downloadButton: {
- backgroundColor: '#34C759',
- borderRadius: 12,
- paddingVertical: 12,
- alignItems: 'center',
- },
- downloadButtonText: {
- color: '#fff',
- fontSize: 15,
- fontWeight: '600',
- },
- statusRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 8,
- marginTop: 10,
- },
- statusText: {
- fontSize: 13,
- color: '#8E8E93',
- textAlign: 'center',
- },
- errorText: {
- fontSize: 13,
- color: '#FF3B30',
- textAlign: 'center',
- marginBottom: 16,
- },
- langChip: {
- borderColor: '#C7C7CC',
- },
- langChipActive: {
- backgroundColor: '#007AFF',
- borderColor: '#007AFF',
- },
- card: {
- backgroundColor: '#fff',
- borderRadius: 12,
- padding: 14,
- marginBottom: 10,
- borderLeftWidth: 4,
- },
- cardHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- marginBottom: 6,
- },
- badge: {
- paddingHorizontal: 10,
- paddingVertical: 3,
- borderRadius: 10,
- },
- badgeText: {
- color: '#fff',
- fontSize: 12,
- fontWeight: '600',
- },
- range: {
- fontSize: 12,
- color: '#8E8E93',
- fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
- },
- matchedText: {
- fontSize: 15,
- color: '#000',
- fontWeight: '500',
- },
- dataContainer: {
- marginTop: 6,
- paddingTop: 6,
- borderTopWidth: StyleSheet.hairlineWidth,
- borderTopColor: '#E5E5EA',
- },
- dataText: {
- fontSize: 13,
- color: '#636366',
- fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
- },
-});
diff --git a/example/app.json b/example/app.json
index 79e3c7e..d62ed69 100644
--- a/example/app.json
+++ b/example/app.json
@@ -5,7 +5,6 @@
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
- "userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
@@ -21,7 +20,6 @@
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
- "edgeToEdgeEnabled": true,
"package": "com.example.datadetector"
},
"web": {
@@ -35,7 +33,8 @@
"minSdkVersion": 26
}
}
- ]
+ ],
+ "expo-status-bar"
]
}
}
diff --git a/example/components/DetectButton.tsx b/example/components/DetectButton.tsx
new file mode 100644
index 0000000..bbfa796
--- /dev/null
+++ b/example/components/DetectButton.tsx
@@ -0,0 +1,45 @@
+import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native';
+
+import { C } from '../theme';
+
+interface Props {
+ detecting: boolean;
+ isReady: boolean;
+ onPress: () => void;
+}
+
+export function DetectButton({ detecting, isReady, onPress }: Props) {
+ const disabled = detecting || !isReady;
+
+ return (
+
+ {detecting ? (
+
+ ) : (
+ {isReady ? 'Detect' : 'Preparing model…'}
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ backgroundColor: C.accent,
+ borderRadius: 14,
+ paddingVertical: 15,
+ alignItems: 'center',
+ marginTop: 12,
+ },
+ disabled: {
+ opacity: 0.5,
+ },
+ text: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '700',
+ },
+});
diff --git a/example/components/DetectInput.tsx b/example/components/DetectInput.tsx
new file mode 100644
index 0000000..e30d2bd
--- /dev/null
+++ b/example/components/DetectInput.tsx
@@ -0,0 +1,43 @@
+import { StyleSheet, TextInput, View } from 'react-native';
+
+import { C } from '../theme';
+
+interface Props {
+ value: string;
+ onChangeText: (text: string) => void;
+ placeholder?: string;
+}
+
+export function DetectInput({ value, onChangeText, placeholder }: Props) {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ backgroundColor: C.surface,
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: C.border,
+ marginTop: 16,
+ },
+ input: {
+ color: C.text,
+ fontSize: 16,
+ lineHeight: 22,
+ padding: 16,
+ minHeight: 56,
+ maxHeight: 130,
+ textAlignVertical: 'top',
+ },
+});
diff --git a/example/components/DetectedList.tsx b/example/components/DetectedList.tsx
new file mode 100644
index 0000000..20f14dc
--- /dev/null
+++ b/example/components/DetectedList.tsx
@@ -0,0 +1,83 @@
+import { ActivityIndicator, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
+import type { DetectedEntity, ModelStatus } from 'react-native-data-detector';
+
+import { C, SCREEN_PADDING } from '../theme';
+import { EntityChip } from './EntityChip';
+import type { Mode } from './ModeToggle';
+
+interface Props {
+ entities: DetectedEntity[];
+ busy: boolean;
+ status: ModelStatus;
+ mode: Mode;
+}
+
+export function DetectedList({ entities, busy, status, mode }: Props) {
+ const preparing = Platform.OS === 'android' && status !== 'ready';
+
+ return (
+
+
+ Detected · {entities.length}
+ {busy && }
+
+
+ {entities.length === 0 ? (
+
+
+ {preparing
+ ? 'Preparing model…'
+ : mode === 'reactive'
+ ? 'Start typing to detect…'
+ : 'Tap Detect to analyze.'}
+
+
+ ) : (
+
+ {entities.map((entity, index) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 10,
+ },
+ label: {
+ fontSize: 12,
+ fontWeight: '700',
+ color: C.muted,
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ },
+ empty: {
+ height: 76,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: C.border,
+ borderStyle: 'dashed',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ emptyText: { color: C.muted, fontSize: 13 },
+ scroll: {
+ marginHorizontal: -SCREEN_PADDING,
+ },
+ strip: {
+ gap: 10,
+ paddingHorizontal: SCREEN_PADDING,
+ },
+});
diff --git a/example/components/EntityChip.tsx b/example/components/EntityChip.tsx
new file mode 100644
index 0000000..a85b82e
--- /dev/null
+++ b/example/components/EntityChip.tsx
@@ -0,0 +1,62 @@
+import { StyleSheet, Text, View } from 'react-native';
+import type { DetectedEntity } from 'react-native-data-detector';
+
+import { TYPE_COLORS, TYPE_LABELS } from '../constants';
+import { C } from '../theme';
+
+interface Props {
+ entity: DetectedEntity;
+}
+
+export function EntityChip({ entity }: Props) {
+ const color = TYPE_COLORS[entity.type];
+
+ return (
+
+
+
+ {TYPE_LABELS[entity.type]}
+
+
+ {entity.text}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ chip: {
+ backgroundColor: C.surface,
+ borderWidth: 1,
+ borderColor: C.border,
+ borderRadius: 14,
+ paddingVertical: 11,
+ paddingHorizontal: 14,
+ minWidth: 124,
+ maxWidth: 220,
+ height: 76,
+ justifyContent: 'center',
+ gap: 7,
+ },
+ head: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 7,
+ },
+ dot: {
+ width: 7,
+ height: 7,
+ borderRadius: 4,
+ },
+ type: {
+ fontSize: 11,
+ fontWeight: '700',
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ },
+ text: {
+ color: C.text,
+ fontSize: 15,
+ fontWeight: '600',
+ },
+});
diff --git a/example/components/Header.tsx b/example/components/Header.tsx
new file mode 100644
index 0000000..abb20b2
--- /dev/null
+++ b/example/components/Header.tsx
@@ -0,0 +1,32 @@
+import { Platform, StyleSheet, Text, View } from 'react-native';
+
+import { C } from '../theme';
+
+export function Header() {
+ return (
+
+ Data Detector
+
+ {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ marginBottom: 20,
+ },
+ title: {
+ fontSize: 30,
+ fontWeight: '800',
+ color: C.text,
+ letterSpacing: -0.5,
+ },
+ subtitle: {
+ fontSize: 13,
+ color: C.muted,
+ marginTop: 2,
+ fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
+ },
+});
diff --git a/example/components/LanguageButton.tsx b/example/components/LanguageButton.tsx
new file mode 100644
index 0000000..ae7ace7
--- /dev/null
+++ b/example/components/LanguageButton.tsx
@@ -0,0 +1,45 @@
+import { Pressable, StyleSheet, Text } from 'react-native';
+import type { ModelLanguage } from 'react-native-data-detector';
+
+import { LANGUAGES } from '../constants';
+import { C } from '../theme';
+
+interface Props {
+ language: ModelLanguage;
+ onPress: () => void;
+}
+
+export function LanguageButton({ language, onPress }: Props) {
+ const name = LANGUAGES.find((l) => l.code === language)?.name ?? language;
+
+ return (
+
+ {name}
+ ▾
+
+ );
+}
+
+const styles = StyleSheet.create({
+ dropdown: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ backgroundColor: C.surface,
+ borderWidth: 1,
+ borderColor: C.border,
+ borderRadius: 999,
+ paddingVertical: 8,
+ paddingHorizontal: 14,
+ },
+ text: {
+ color: C.text,
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ caret: {
+ color: C.muted,
+ fontSize: 11,
+ marginTop: 1,
+ },
+});
diff --git a/example/components/LanguageSheet.tsx b/example/components/LanguageSheet.tsx
new file mode 100644
index 0000000..8ddff70
--- /dev/null
+++ b/example/components/LanguageSheet.tsx
@@ -0,0 +1,92 @@
+import { Modal, Pressable, ScrollView, StyleSheet, Text } from 'react-native';
+import type { ModelLanguage } from 'react-native-data-detector';
+
+import { LANGUAGES } from '../constants';
+import { C } from '../theme';
+
+interface Props {
+ visible: boolean;
+ selected: ModelLanguage;
+ onSelect: (code: ModelLanguage) => void;
+ onClose: () => void;
+}
+
+export function LanguageSheet({ visible, selected, onSelect, onClose }: Props) {
+ return (
+
+
+
+ Language model
+
+ {LANGUAGES.map((l) => {
+ const active = l.code === selected;
+
+ return (
+ onSelect(l.code)}>
+ {l.name}
+ {active && ✓}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.55)',
+ justifyContent: 'flex-end',
+ },
+ sheet: {
+ backgroundColor: C.surfaceHi,
+ borderTopLeftRadius: 22,
+ borderTopRightRadius: 22,
+ borderWidth: 1,
+ borderColor: C.border,
+ paddingHorizontal: 4,
+ paddingTop: 18,
+ paddingBottom: 32,
+ },
+ title: {
+ color: C.muted,
+ fontSize: 12,
+ fontWeight: '700',
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ marginBottom: 6,
+ paddingHorizontal: 6,
+ },
+ list: {
+ maxHeight: 360,
+ },
+ listContent: {
+ paddingHorizontal: 12,
+ },
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 14,
+ paddingHorizontal: 6,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: C.border,
+ },
+ rowText: {
+ color: C.text,
+ fontSize: 16,
+ fontWeight: '700',
+ },
+ rowActive: {
+ color: C.accent,
+ fontWeight: '700',
+ },
+ check: {
+ color: C.accent,
+ fontSize: 16,
+ fontWeight: '700',
+ },
+});
diff --git a/example/components/ModeToggle.tsx b/example/components/ModeToggle.tsx
new file mode 100644
index 0000000..b8f45a2
--- /dev/null
+++ b/example/components/ModeToggle.tsx
@@ -0,0 +1,62 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+
+import { C } from '../theme';
+
+export type Mode = 'reactive' | 'imperative';
+
+const OPTIONS: [Mode, string][] = [
+ ['reactive', 'Live'],
+ ['imperative', 'On tap'],
+];
+
+interface Props {
+ mode: Mode;
+ onChange: (mode: Mode) => void;
+}
+
+export function ModeToggle({ mode, onChange }: Props) {
+ return (
+
+ {OPTIONS.map(([value, label]) => {
+ const active = mode === value;
+
+ return (
+ onChange(value)}
+ >
+ {label}
+
+ );
+ })}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ toggle: {
+ flexDirection: 'row',
+ backgroundColor: C.surface,
+ borderRadius: 999,
+ borderWidth: 1,
+ borderColor: C.border,
+ padding: 3,
+ },
+ item: {
+ paddingVertical: 7,
+ paddingHorizontal: 16,
+ borderRadius: 999,
+ },
+ itemActive: {
+ backgroundColor: C.accent,
+ },
+ text: {
+ color: C.muted,
+ fontSize: 13,
+ fontWeight: '600',
+ },
+ textActive: {
+ color: '#fff',
+ },
+});
diff --git a/example/components/ModelStatus.tsx b/example/components/ModelStatus.tsx
new file mode 100644
index 0000000..fc67e93
--- /dev/null
+++ b/example/components/ModelStatus.tsx
@@ -0,0 +1,40 @@
+import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
+import type { ModelStatus as Status } from 'react-native-data-detector';
+
+import { C } from '../theme';
+
+const LABELS: Record = {
+ downloading: 'Downloading model…',
+ error: 'Model error',
+ notDownloaded: 'Model not downloaded',
+};
+
+interface Props {
+ status: Status;
+}
+
+export function ModelStatus({ status }: Props) {
+ if (status === 'ready') {
+ return null;
+ }
+
+ return (
+
+ {status === 'downloading' && }
+ {LABELS[status] ?? status}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginTop: 12,
+ },
+ text: {
+ color: C.muted,
+ fontSize: 13,
+ },
+});
diff --git a/example/constants.ts b/example/constants.ts
new file mode 100644
index 0000000..c785756
--- /dev/null
+++ b/example/constants.ts
@@ -0,0 +1,39 @@
+import type { DetectionType, ModelLanguage } from 'react-native-data-detector';
+
+export const SAMPLE_TEXT =
+ 'Call me at (555) 123-4567 or email john@example.com tomorrow at 9:30pm.';
+
+export const TYPE_COLORS: Record = {
+ phoneNumber: '#A78BFA',
+ link: '#60A5FA',
+ email: '#34D399',
+ address: '#FBBF24',
+ date: '#F472B6',
+};
+
+export const TYPE_LABELS: Record = {
+ phoneNumber: 'Phone',
+ link: 'Link',
+ email: 'Email',
+ address: 'Address',
+ date: 'Date',
+};
+
+/** The 15 language models supported by ML Kit Entity Extraction (Android). */
+export const LANGUAGES: { code: ModelLanguage; name: string }[] = [
+ { code: 'ar', name: 'Arabic' },
+ { code: 'nl', name: 'Dutch' },
+ { code: 'en', name: 'English' },
+ { code: 'fr', name: 'French' },
+ { code: 'de', name: 'German' },
+ { code: 'it', name: 'Italian' },
+ { code: 'ja', name: 'Japanese' },
+ { code: 'ko', name: 'Korean' },
+ { code: 'pl', name: 'Polish' },
+ { code: 'pt', name: 'Portuguese' },
+ { code: 'ru', name: 'Russian' },
+ { code: 'es', name: 'Spanish' },
+ { code: 'th', name: 'Thai' },
+ { code: 'tr', name: 'Turkish' },
+ { code: 'zh', name: 'Chinese' },
+];
diff --git a/example/metro.config.js b/example/metro.config.js
index 6e70bda..4f596ec 100644
--- a/example/metro.config.js
+++ b/example/metro.config.js
@@ -6,20 +6,20 @@ const monorepoRoot = path.resolve(projectRoot, '..');
const config = getDefaultConfig(projectRoot);
-// Watch the parent directory so Metro can resolve the file:.. dependency
+// Watch the repo root so Metro picks up changes to the linked
+// `react-native-data-detector` package (a `file:..` dependency).
config.watchFolders = [monorepoRoot];
-// Ensure Metro resolves from the example's node_modules first
+// Resolve from the example's node_modules first, then the repo root.
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
-// The monorepo root also carries its own (newer) react / react-native, pulled in
-// by npm auto-installing the library's peerDependencies. Pin the singletons to the
-// example's copies so Metro and React Native codegen never mix versions — mixing
-// breaks native component codegen (e.g. "Unable to determine event arguments" in
-// VirtualViewNativeComponent).
+// The repo root carries its own, newer react / react-native (pulled in by the
+// library's peer + test dependencies). Pin the singletons to the example's copies
+// and block the root ones so Metro never mixes versions — mixing causes crashes
+// like "Property 'MessageQueue' doesn't exist".
config.resolver.extraNodeModules = {
react: path.resolve(projectRoot, 'node_modules/react'),
'react-native': path.resolve(projectRoot, 'node_modules/react-native'),
diff --git a/example/package.json b/example/package.json
index 840faa2..3c0dab6 100644
--- a/example/package.json
+++ b/example/package.json
@@ -9,16 +9,17 @@
"web": "expo start --web"
},
"dependencies": {
- "expo": "~54.0.33",
- "expo-build-properties": "~1.0.10",
- "expo-status-bar": "~3.0.9",
- "react": "19.1.0",
- "react-native": "0.81.5",
- "react-native-data-detector": "file:.."
+ "expo": "^56.0.9",
+ "expo-build-properties": "~56.0.17",
+ "expo-status-bar": "~56.0.4",
+ "react": "19.2.3",
+ "react-native": "0.85.3",
+ "react-native-data-detector": "file:..",
+ "react-native-safe-area-context": "~5.7.0"
},
"devDependencies": {
- "@types/react": "~19.1.0",
- "typescript": "~5.9.2"
+ "@types/react": "~19.2.14",
+ "typescript": "~6.0.3"
},
"private": true
}
diff --git a/example/screens/DataDetector.tsx b/example/screens/DataDetector.tsx
new file mode 100644
index 0000000..afd179a
--- /dev/null
+++ b/example/screens/DataDetector.tsx
@@ -0,0 +1,124 @@
+import { StatusBar } from 'expo-status-bar';
+import { useState } from 'react';
+import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native';
+import {
+ useDataDetector,
+ useDetectedEntities,
+ type DetectedEntity,
+ type ModelLanguage,
+} from 'react-native-data-detector';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { DetectButton } from '../components/DetectButton';
+import { DetectInput } from '../components/DetectInput';
+import { DetectedList } from '../components/DetectedList';
+import { Header } from '../components/Header';
+import { LanguageButton } from '../components/LanguageButton';
+import { LanguageSheet } from '../components/LanguageSheet';
+import { ModeToggle, type Mode } from '../components/ModeToggle';
+import { ModelStatus } from '../components/ModelStatus';
+import { SAMPLE_TEXT } from '../constants';
+import { C, SCREEN_PADDING } from '../theme';
+
+export default function DataDetector() {
+ const insets = useSafeAreaInsets();
+
+ const [text, setText] = useState(SAMPLE_TEXT);
+ const [language, setLanguage] = useState('en');
+ const [mode, setMode] = useState('reactive');
+ const [langOpen, setLangOpen] = useState(false);
+
+ // Imperative hook: model lifecycle + a detect() you call yourself.
+ const { detect, status, isReady } = useDataDetector({ language });
+
+ // Reactive hook: debounced detection as `text` changes (paused in "On tap" mode).
+ const { entities: liveEntities, isDetecting } = useDetectedEntities(text, {
+ language,
+ debounceMs: 200,
+ enabled: mode === 'reactive',
+ });
+
+ const [tappedEntities, setTappedEntities] = useState([]);
+ const [detecting, setDetecting] = useState(false);
+
+ const entities = mode === 'reactive' ? liveEntities : tappedEntities;
+ const busy = mode === 'reactive' ? isDetecting : detecting;
+ const isAndroid = Platform.OS === 'android';
+
+ const handleDetect = async () => {
+ setDetecting(true);
+ detect(text)
+ .then(setTappedEntities)
+ .catch(() => setTappedEntities([]))
+ .finally(() => setDetecting(false));
+ };
+
+ return (
+
+
+
+
+
+
+
+ {isAndroid ? (
+ setLangOpen(true)} />
+ ) : (
+
+ )}
+
+
+
+ {isAndroid && }
+
+
+
+
+
+ {mode === 'imperative' && (
+
+ )}
+
+
+
+ {
+ setLanguage(code);
+ setLangOpen(false);
+ }}
+ onClose={() => setLangOpen(false)}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: C.bg,
+ },
+ flex: {
+ flex: 1,
+ },
+ body: {
+ flex: 1,
+ paddingHorizontal: SCREEN_PADDING,
+ },
+ controls: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ spacer: {
+ flex: 1,
+ minHeight: 16,
+ },
+});
diff --git a/example/theme.ts b/example/theme.ts
new file mode 100644
index 0000000..7a58156
--- /dev/null
+++ b/example/theme.ts
@@ -0,0 +1,13 @@
+/** Shared visual tokens for the example app. */
+export const C = {
+ bg: '#0B0C10',
+ surface: '#16181D',
+ surfaceHi: '#1E2128',
+ border: 'rgba(255,255,255,0.08)',
+ text: '#F4F4F6',
+ muted: '#8B8D98',
+ accent: '#6366F1',
+} as const;
+
+/** Horizontal screen inset. Shared so full-bleed rows can cancel it precisely. */
+export const SCREEN_PADDING = 20;
diff --git a/src/index.ts b/src/index.ts
index afe8ba1..4d84863 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,8 @@ export {
} from './ReactNativeDataDetector';
export { useDataDetector } from './useDataDetector';
export type { UseDataDetectorOptions, UseDataDetectorResult } from './useDataDetector';
+export { useDetectedEntities } from './useDetectedEntities';
+export type { UseDetectedEntitiesOptions, UseDetectedEntitiesResult } from './useDetectedEntities';
export type {
DetectedEntity,
DetectionType,
diff --git a/src/useDataDetector.ts b/src/useDataDetector.ts
index dc73bba..306ab04 100644
--- a/src/useDataDetector.ts
+++ b/src/useDataDetector.ts
@@ -1,14 +1,13 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback } from 'react';
-import { detect as detectFn, getModelStatus, prepareModel } from './ReactNativeDataDetector';
+import { detect as detectFn } from './ReactNativeDataDetector';
import type {
DetectedEntity,
DetectionType,
ModelLanguage,
ModelStatus,
} from './ReactNativeDataDetector.types';
-
-const DEFAULT_LANGUAGE: ModelLanguage = 'en';
+import { DEFAULT_LANGUAGE, useModelLifecycle } from './useModelLifecycle';
export interface UseDataDetectorOptions {
/**
@@ -42,8 +41,12 @@ export interface UseDataDetectorResult {
}
/**
- * React hook for data detection that tracks model availability and, on Android,
- * downloads the language model automatically.
+ * React hook for **imperative** data detection: it tracks model availability and,
+ * on Android, downloads the language model automatically, then hands you a `detect`
+ * function to call when you want (e.g. once per chat message).
+ *
+ * For **reactive** detection of a changing string (as-you-type), use
+ * {@link useDetectedEntities} instead.
*
* On iOS the model is always available, so `status` settles on `'ready'` and
* `autoPrepare` has no effect.
@@ -56,61 +59,7 @@ export function useDataDetector(options?: UseDataDetectorOptions): UseDataDetect
const language = options?.language ?? DEFAULT_LANGUAGE;
const autoPrepare = options?.autoPrepare ?? true;
- const [status, setStatus] = useState('notDownloaded');
- const [error, setError] = useState(null);
-
- // The language the hook currently cares about. Lets late-resolving promises
- // from a previous language (after a switch or unmount) be ignored.
- const activeLanguage = useRef(language);
-
- const prepare = useCallback(async () => {
- setError(null);
- setStatus('downloading');
- try {
- await prepareModel({ language });
- if (activeLanguage.current === language) setStatus('ready');
- } catch (e) {
- const err = e instanceof Error ? e : new Error(String(e));
- if (activeLanguage.current === language) {
- setError(err);
- setStatus('error');
- }
- throw err;
- }
- }, [language]);
-
- useEffect(() => {
- activeLanguage.current = language;
- let active = true;
-
- (async () => {
- setError(null);
- try {
- const current = await getModelStatus({ language });
- if (!active) return;
- if (current === 'ready') {
- setStatus('ready');
- return;
- }
- if (!autoPrepare) {
- setStatus(current);
- return;
- }
- setStatus('downloading');
- await prepareModel({ language });
- if (active) setStatus('ready');
- } catch (e) {
- if (active) {
- setError(e instanceof Error ? e : new Error(String(e)));
- setStatus('error');
- }
- }
- })();
-
- return () => {
- active = false;
- };
- }, [language, autoPrepare]);
+ const { status, isReady, prepare, error } = useModelLifecycle(language, autoPrepare);
const detect = useCallback(
(text: string, opts?: { types?: DetectionType[] }): Promise =>
@@ -118,5 +67,5 @@ export function useDataDetector(options?: UseDataDetectorOptions): UseDataDetect
[language],
);
- return { detect, prepare, status, isReady: status === 'ready', error };
+ return { detect, prepare, status, isReady, error };
}
diff --git a/src/useDetectedEntities.ts b/src/useDetectedEntities.ts
new file mode 100644
index 0000000..2e60356
--- /dev/null
+++ b/src/useDetectedEntities.ts
@@ -0,0 +1,122 @@
+import { useEffect, useRef, useState } from 'react';
+
+import { detect as detectFn } from './ReactNativeDataDetector';
+import type {
+ DetectedEntity,
+ DetectionType,
+ ModelLanguage,
+ ModelStatus,
+} from './ReactNativeDataDetector.types';
+import { DEFAULT_LANGUAGE, useModelLifecycle } from './useModelLifecycle';
+
+export interface UseDetectedEntitiesOptions {
+ /** Debounce applied to `text` before detecting, in ms. Defaults to `300`. */
+ debounceMs?: number;
+ /** Which entity types to detect. Defaults to all types. */
+ types?: DetectionType[];
+ /**
+ * Which language model to use (Android only, defaults to `'en'`). Ignored on iOS.
+ */
+ language?: ModelLanguage;
+ /** When `false`, detection is paused and the last result is kept. Defaults to `true`. */
+ enabled?: boolean;
+ /**
+ * When `true` (default), download the model on mount if needed (Android).
+ * No effect on iOS.
+ */
+ autoPrepare?: boolean;
+}
+
+export interface UseDetectedEntitiesResult {
+ /** Entities detected in the (debounced) `text`. Empty until the first result. */
+ entities: DetectedEntity[];
+ /** `true` while a detection for the latest text is in flight. */
+ isDetecting: boolean;
+ /** Current model download state. */
+ status: ModelStatus;
+ /** The last detection or model error, or `null`. */
+ error: Error | null;
+}
+
+/**
+ * React hook for **reactive** data detection: pass a (possibly changing) string and
+ * get back the detected entities, recomputed as the text changes. Debounced and
+ * cancellation-safe (last write wins), so it is suited to as-you-type input.
+ *
+ * Manages model readiness internally (auto-downloads on Android). For **imperative**
+ * detection where you call `detect` yourself, use {@link useDataDetector} instead.
+ *
+ * @example
+ * const { entities, isDetecting } = useDetectedEntities(text, { debounceMs: 250 });
+ */
+export function useDetectedEntities(
+ text: string,
+ options?: UseDetectedEntitiesOptions,
+): UseDetectedEntitiesResult {
+ const [entities, setEntities] = useState([]);
+ const [isDetecting, setIsDetecting] = useState(false);
+ const [detectError, setDetectError] = useState(null);
+ const [debouncedText, setDebouncedText] = useState(text);
+
+ const debounceMs = options?.debounceMs ?? 300;
+ const language = options?.language ?? DEFAULT_LANGUAGE;
+ const enabled = options?.enabled ?? true;
+ const autoPrepare = options?.autoPrepare ?? true;
+
+ const types = options?.types;
+ const typesKey = types?.join(',') ?? '';
+
+ const { status, isReady, error: modelError } = useModelLifecycle(language, autoPrepare);
+
+ const runId = useRef(0);
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+ const id = setTimeout(() => setDebouncedText(text), debounceMs);
+
+ return () => clearTimeout(id);
+ }, [text, debounceMs, enabled]);
+
+ useEffect(() => {
+ if (!enabled || !isReady) {
+ return;
+ }
+
+ if (!debouncedText) {
+ runId.current += 1;
+ setEntities([]);
+ setIsDetecting(false);
+ setDetectError(null);
+ return;
+ }
+
+ const currentRunId = ++runId.current;
+
+ setIsDetecting(true);
+ setDetectError(null);
+ detectFn(debouncedText, { types, language })
+ .then((res) => {
+ if (currentRunId !== runId.current) {
+ return;
+ }
+ setEntities(res);
+ setIsDetecting(false);
+ })
+ .catch((e) => {
+ if (currentRunId !== runId.current) {
+ return;
+ }
+ setDetectError(e instanceof Error ? e : new Error(String(e)));
+ setIsDetecting(false);
+ });
+ }, [debouncedText, isReady, enabled, language, typesKey]);
+
+ return {
+ entities,
+ isDetecting,
+ status,
+ error: detectError ?? modelError,
+ };
+}
diff --git a/src/useModelLifecycle.ts b/src/useModelLifecycle.ts
new file mode 100644
index 0000000..cdecb21
--- /dev/null
+++ b/src/useModelLifecycle.ts
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { getModelStatus, prepareModel } from './ReactNativeDataDetector';
+import type { ModelLanguage, ModelStatus } from './ReactNativeDataDetector.types';
+
+export const DEFAULT_LANGUAGE: ModelLanguage = 'en';
+
+export interface ModelLifecycle {
+ /** Current model download state. */
+ status: ModelStatus;
+ /** `true` when the model is available and `detect()` can run offline. */
+ isReady: boolean;
+ /** Manually (re)download the model for the configured language. */
+ prepare: () => Promise;
+ /** The last preparation error, or `null`. */
+ error: Error | null;
+}
+
+/**
+ * Internal hook shared by {@link useDataDetector} and {@link useDetectedEntities}.
+ * Tracks model availability for a language and, on Android, downloads it
+ * automatically on mount when `autoPrepare` is set. On iOS the model is always
+ * available, so `status` settles on `'ready'`.
+ *
+ * Not part of the public API.
+ */
+export function useModelLifecycle(language: ModelLanguage, autoPrepare: boolean): ModelLifecycle {
+ const [status, setStatus] = useState('notDownloaded');
+ const [error, setError] = useState(null);
+
+ // The language the hook currently cares about. Lets late-resolving promises
+ // from a previous language (after a switch or unmount) be ignored.
+ const activeLanguage = useRef(language);
+
+ const prepare = useCallback(async () => {
+ setError(null);
+ setStatus('downloading');
+ try {
+ await prepareModel({ language });
+ if (activeLanguage.current === language) setStatus('ready');
+ } catch (e) {
+ const err = e instanceof Error ? e : new Error(String(e));
+ if (activeLanguage.current === language) {
+ setError(err);
+ setStatus('error');
+ }
+ throw err;
+ }
+ }, [language]);
+
+ useEffect(() => {
+ activeLanguage.current = language;
+ let active = true;
+
+ (async () => {
+ setError(null);
+ try {
+ const current = await getModelStatus({ language });
+ if (!active) return;
+ if (current === 'ready') {
+ setStatus('ready');
+ return;
+ }
+ if (!autoPrepare) {
+ setStatus(current);
+ return;
+ }
+ setStatus('downloading');
+ await prepareModel({ language });
+ if (active) setStatus('ready');
+ } catch (e) {
+ if (active) {
+ setError(e instanceof Error ? e : new Error(String(e)));
+ setStatus('error');
+ }
+ }
+ })();
+
+ return () => {
+ active = false;
+ };
+ }, [language, autoPrepare]);
+
+ return { status, isReady: status === 'ready', prepare, error };
+}