From rn-lib-claude
Use when implementing native modules (TurboModules) or native views (Fabric) for a React Native library — Codegen TypeScript specs, threading model, iOS/Android registration, view flattening, New Architecture only.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rn-lib-claude:codegenThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
New Architecture only. Codegen generates native interfaces from TypeScript specs.
New Architecture only. Codegen generates native interfaces from TypeScript specs.
Sync method → blocks JS thread for its entire duration
MUST complete in <16ms (one frame budget)
Use only for simple reads/computations
Async method → runs on native module background thread
Promise-based; safe for any duration
Use for I/O, heavy computation, network
// TypeScript spec
export interface Spec extends TurboModule {
// Sync — fast only, <16ms
getDeviceId(): string
multiply(a: number, b: number): number
// Async — safe for heavy work
processImage(uri: string): Promise<{ uri: string; size: number }>
readFile(path: string): Promise<string>
}
// Sync — runs on JS thread, keep fast
- (double)multiply:(double)a b:(double)b {
return a * b; // pure computation, instant
}
// Async — dispatch to background
- (void)processImage:(NSString *)uri resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// heavy work here
NSString *result = [self doHeavyWork:uri];
resolve(result);
});
}
// UI updates — must dispatch to main thread
- (void)updateUI:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
dispatch_async(dispatch_get_main_queue(), ^{
// UIKit operations here
resolve(nil);
});
}
// Sync — JS thread, keep fast
override fun multiply(a: Double, b: Double): Double = a * b
// Async — use coroutines on background dispatcher
override fun processImage(uri: String, promise: Promise) {
CoroutineScope(Dispatchers.Default + Job()).launch {
try {
val result = doHeavyWork(uri)
promise.resolve(result)
} catch (e: Exception) {
promise.reject("ERR_PROCESSING", e.message, e)
}
}
}
// Always clean up coroutines on module invalidation
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun invalidate() {
super.invalidate()
scope.cancel() // ← mandatory, prevents leaks
}
src/NativeMyLib.ts)import type { TurboModule } from 'react-native'
import { TurboModuleRegistry } from 'react-native'
import type { Double, Int32 } from 'react-native/Libraries/Types/CodegenTypes'
export interface Spec extends TurboModule {
// Sync — primitives only, fast ops
multiply(a: Double, b: Double): Double
getVersion(): string
// Async — I/O, heavy computation
processImage(uri: string, options: {
width: Int32
height: Int32
quality?: Double
}): Promise<{ uri: string; size: Int32 }>
// Events (if module emits)
addListener(eventName: string): void
removeListeners(count: Int32): void
}
export default TurboModuleRegistry.getEnforcing<Spec>('RNMyLib')
package.json Codegen Config"codegenConfig": {
"name": "RNMyLibSpec",
"type": "modules",
"jsSrcsDir": "src",
"android": { "javaPackageName": "com.mylib" }
}
ios/RNMyLib.mm)#import "RNMyLib.h"
@implementation RNMyLib
RCT_EXPORT_MODULE()
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared<facebook::react::NativeRNMyLibSpecJSI>(params);
}
- (double)multiply:(double)a b:(double)b { return a * b; }
- (void)processImage:(NSString *)uri options:(JS::NativeRNMyLib::SpecProcessImageOptions &)options
resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// heavy work
resolve(@{ @"uri": uri, @"size": @(1024) });
});
}
@end
android/src/main/java/com/mylib/MyLibModule.kt)package com.mylib
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
import com.mylib.NativeRNMyLibSpec
import kotlinx.coroutines.*
class MyLibModule(reactContext: ReactApplicationContext) : NativeRNMyLibSpec(reactContext) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun getName() = NAME
override fun multiply(a: Double, b: Double): Double = a * b
override fun processImage(uri: String, options: ReadableMap, promise: Promise) {
scope.launch {
try {
promise.resolve(uri)
} catch (e: Exception) {
promise.reject("ERR_PROCESS", e.message, e)
}
}
}
override fun invalidate() {
super.invalidate()
scope.cancel()
}
companion object { const val NAME = "RNMyLib" }
}
src/MyLibNativeComponent.ts)import type { HostComponent, ViewProps } from 'react-native'
import type { DirectEventHandler, Double, Int32 } from 'react-native/Libraries/Types/CodegenTypes'
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'
type OnLoadEvent = { width: Double; height: Double; duration: Double }
type NativeProps = ViewProps & {
uri: string
resizeMode?: 'cover' | 'contain' | 'stretch'
autoPlay?: boolean
muted?: boolean
onLoad?: DirectEventHandler<OnLoadEvent>
onError?: DirectEventHandler<{ message: string; code: Int32 }>
onProgress?: DirectEventHandler<{ position: Double; duration: Double }>
}
export default codegenNativeComponent<NativeProps>('MyLibView') as HostComponent<NativeProps>
package.json Codegen Config for Views"codegenConfig": {
"name": "RNMyLibSpec",
"type": "components",
"jsSrcsDir": "src"
}
collapsable={false}Fabric aggressively flattens layout-only views. If a native component expects a specific child at a specific index, the child may be removed silently.
// ❌ Fabric may remove this wrapper — native code breaks
<View style={styles.wrapper}>
<NativeVideoView />
</View>
// ✅ Prevent flattening
<View style={styles.wrapper} collapsable={false}>
<NativeVideoView />
</View>
Detect flattening issues with:
Properties that prevent flattening (no need for collapsable): backgroundColor, borderWidth, shadowColor, event handlers, opacity < 1, overflow: 'hidden'.
| TypeScript | Import | Notes |
|---|---|---|
Double | CodegenTypes | 64-bit float |
Float | CodegenTypes | 32-bit float |
Int32 | CodegenTypes | 32-bit integer |
string | — | native string |
boolean | — | native bool |
ReadonlyArray<T> | — | typed array |
DirectEventHandler<T> | CodegenTypes | fires on target only |
BubblingEventHandler<T> | CodegenTypes | bubbles up tree |
Two separate concerns. Do not confuse them.
expo-module.config.json — Auto-linkingTells Expo's auto-linker which native classes to register. Without this file, the library won't link in Expo managed or bare workflow.
The class names must match what you named your native classes. Use the actual class names from your iOS and Android implementation.
TurboModule — the iOS class is RNMyLib (from RCT_EXPORT_MODULE()), Android class is MyLibModule (from getName()):
{
"platforms": ["apple", "android"],
"ios": {
"modules": ["RNMyLib"]
},
"android": {
"modules": ["com.mylib.MyLibModule"]
}
}
Fabric view — the iOS view component is RNMyLibView, Android is MyLibViewManager:
{
"platforms": ["apple", "android"],
"ios": {
"components": ["RNMyLibView"]
},
"android": {
"components": ["com.mylib.MyLibViewManager"]
}
}
How to find the right class names:
RCT_EXPORT_MODULE() or RCT_EXPORT_VIEW_COMPONENT() in your .mm file — the argument (or class name if no argument) is what goes hereNativeXXXSpec or SimpleViewManager — use the fully qualified class nameAdd "expo-module.config.json" to files in package.json so it ships with the package.
app.plugin.js — Config Plugin (only when needed)A config plugin modifies the consumer's native project at build time — adding permissions to AndroidManifest.xml, entries to Info.plist, Gradle dependencies, etc.
You only need this if your library requires native project configuration beyond just linking. Examples:
NSCameraUsageDescription to Info.plistUIBackgroundModesMost simple TurboModules and Fabric views do NOT need a config plugin — auto-linking is sufficient.
If you do need one:
// app.plugin.js
const { withInfoPlist } = require('@expo/config-plugins')
module.exports = function withMyLib(config) {
return withInfoPlist(config, config => {
config.modResults.NSCameraUsageDescription = 'Required for camera preview'
return config
})
}
Then add to package.json:
{
"main": "app.plugin.js"
}
The directory entry field configPlugin: true refers to this app.plugin.js — not expo-module.config.json. Every native library should have expo-module.config.json. Only libraries that modify native project config need app.plugin.js.
TurboModuleRegistry.getEnforcing — never NativeModulescodegenNativeComponent — never requireNativeComponentinvalidate()dispatch_async for work >1msbuild/ or android/build/getName() on Android, RCT_EXPORT_MODULE() on iOSCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub shankulkarni/claude-plugin-marketplace --plugin rn-lib-claude