RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

「エンジニア&マネジメント未経験からのPdM/EM兼務」という挑戦

この記事は ノバセル Advent Calendar 21日目です。

こんにちは。ノバセルにてプロダクトマネージャーをやっている林です。 7日目の記事に続き、2回目の登場です。

はじめに

本記事では、今年私が経験したちょっと特殊な出来事についてお話します。それは、エンジニアリングもピープルマネジメントもどちらも経験がない状態で、PdMの仕事に加えてEMの役割まで兼務することになった、というものです。

※補足:厳密に言うとEMの責務を全て担当したわけではなく(それは無謀すぎます笑)、こちらのEM定義におけるプロダクトマネジメント/ピープルマネジメントの領域を主に担当しました。 テクノロジーマネジメント/プロジェクトマネジメントの領域は、メンバーの中に別に担当を立て、その方と協力しつつ一緒に推進しました。

PdMとEMを兼務している場合はあるとしても、基本的にはエンジニアリング/ピープルマネジメントのどちらかはやったことがある前提であることが多いと思います。 しかし、私の場合、両方未経験で兼務するというチャレンジを半年間しました。

その半年間は、まさに手探りの連続でした。 「1on1ってどうすれば上手くいくんだっけ?」「人事評価ってどうすればいいんだろう?」。 そんな基本的なことすら分からず、色々試行錯誤しながら進めていました。もちろん、上手くいったこともあれば、全然ダメだったこともたくさんあります。

この記事では、そんなジェットコースターのような半年間で、私が実際に体験したことを共有したいと思います。

半年間にあったことを詳細に語るのは紙幅の都合上難しいので、以下では

  • 兼務を引き受けた時の心境

  • 上手くいった点

  • 難しかった点

についてお伝えできればと思います。

兼務を引き受けた時の心境

(兼務になった背景は長くなるので割愛しますが)初めて兼務の話を聞いた時、正直かなり戸惑いました。

確かにPdMとしてはそれなりに経験を積んで来ましたが、私自身はBiz出身のPdMなので、エンジニアリングの各論になると分からないこともあります。 それに、ピープルマネジメント経験も全くありませんでした。 「そんな私が、いきなりチームを率いることが本当にできるんだろうか?」 何日もグルグル悩んだことを覚えています。

最終的に引き受けた理由としては、「この挑戦をやり抜けば、PdMとしても人間としても、めちゃくちゃ成長できるんじゃないか?」「このまま逃げ出すのは、あまりにも勿体ない」という思いが湧いたためでした。 「状況は捉えようによっていくらでもプラスに変えられる」と前向きに考えて飛び込めたのは、今振り返っても非常に良かったと思っています。

また※補足でも書いた通り、EMの役割の全てを一気に担う訳ではなく、メンバーと役割分担しながらだったのでチャレンジしやすかった、という面も非常に大きかったです。

色々な葛藤がありましたが、私はPdM/EM兼務というチャレンジを引き受けることにしました。

上手くいった点

PdMとEMの兼務を始めてまず最初に取り組んだのは、チームメンバーとの信頼関係を築くことでした。

「よく分からない人がやってきて、抽象的な目標を掲げて仕切り始める」という状況は最も避けなければいけないと当初から思っていました。 そうならないために、まずはメンバーがどんな人柄なのか、何を考えているのか、どんな強みを持っているのか、をじっくり把握することから始めました。信頼関係がない状態で、「こういう方向でいきたい!」って言ったところで、誰にも響かないですからね。

そこで、特に重要視したのが、1on1での会話です。メンバーと一対一でじっくり話せる1on1の時間を、どう有効活用できるかを、色々と試行錯誤しました。

特に意識したのは、「メンバー自身を知る」ことから始めることでした。 ノバセルには、入社時に書く「自己紹介ページ」というものがあります。1on1の初期は、それを見ながら、「どんなキャリアを辿ってきたのか?」「なぜノバセルを選んだのか?」「ノバセルでどんなことをしたいと思っているのか?」といったことを、深く掘り下げて聞きました。

その中で、自然とWill/Can/Must的な話も聞けたので、メンバーの個性を理解する上で、非常に役立ちました。

そして、もう一つ、効果的だったなと思うのは、自分がボトルネックにならないように、チーム体制の構築に力を入れたことです。

兼務を始めた当初から、「自分がボトルネックになってはいけない」ということは、強く意識していました。もし、プロダクトや組織に関する全ての意思決定を私が行うことになったら、自分が単一障害点になるのは目に見えていました。

そこで、マネジメントに必要な役割を分解し、それぞれの役割をチームメンバーに権限移譲することにしました。具体的には、マネジメントに必要な要素を以下の3つに分解しました。

  • プロダクトマネジメント: 顧客に必要な機能を発見し、ソリューション検討をリードする役割

  • チームリード: チーム運営における意思決定をリードする役割(スクラムの円滑な運営、スクラムマスターっぽい仕事等)

  • テックリード: 開発における技術的な意思決定をリードする役割(仕様の詳細化など)

このうち、最初のプロダクトマネジメントは私が担当し、残りの2つは信頼できるチームメンバーに移譲することにしました。もちろん、ただ丸投げするのではなく、「こういうことを期待している」ということを1on1で会話しながら認識合わせをし、困ったことがあれば1on1で相談してもらうようにしました。

早めにこの分業体制を作れたことは、チームをワークさせる上で非常に効果的だったと思います。と同時に、この体制がうまくいったのは、自主的に動いてくれる優秀なメンバーの方々に支えられたからだと、心底思っています。

例えば、チームビルディングのワークショップをメンバー主導で開催してくれたり、技術力を高めるための「技術1on1」をメンバー間で自主的に開催してくれたり。本当に、チームの皆さんには感謝しかありません。

難しかった点

分業体制を敷いて、ある程度の負担軽減には成功したものの、やはりPdMとEMの兼務は、想像以上に大変でした。特に苦労したのは、プロダクトの中長期的な方針を考える余裕がなくなってしまったことです。

チームメンバー5人全員との週1回の1on1、それに加えてチーム運営の課題への対応など、どうしても日々の業務に追われてしまう時間が増えます。

そうなると、どうしても短期志向になりがちです。目の前にある機能をデリバリーすることだけに意識が集中してしまい、「それって本当にユーザーにとって価値があるものなのか?」「もっと他にやるべきことがあるんじゃないか?」といった、より大きな視点での検討がおろそかになってしまいます。

理想としては、もっとユーザーと直接会話する機会を増やして新しいプロダクトの種を見つけたり、6ヶ月後/1年後/3年後といった中長期の視点でプロダクトのあるべき姿を考えることに時間を使うべきです。しかし、目の前のタスクをこなすだけで精一杯で、なかなかそこまで手が回らなくなったというのが実態です。

正直に言うと、この課題は兼務していた期間中は最後まで解消することができませんでした。 現在は、PdM/EMの兼務体制は解消しています。PdMの役割のみになった今はプロダクトマネジメントに集中できているので、中長期的な方針を考える時間もしっかり確保できており、いいバランスに戻ったなと思っています。

やはり、「PdMとEMが分業されているのには理由がある」と改めて実感しました。それぞれの役割には、それぞれ異なる専門性と責任範囲があり、それを一人で担うというのは何かがトレードオフになります。もちろん、兼務によって得られた学びもたくさんありましたが、同時に「適切に役割分担することの重要性」も身をもって痛感しました。

まとめ

今回は「エンジニア/マネジメント未経験でPdM/EMを兼務してみた学び」をお話しました。 同じようにPdM/EMの兼務に悩む人、PdM/EMの役割に興味がある人にこの記事が少しでも役に立てば嬉しいです。

明日は今年の新卒メンバーが、BI as Codeに関する記事を書いてくれるそうです。お楽しみに!

DagsterでTimeoutが起きた時に自動でリトライさせて面倒を減らした話

この記事は ノバセル Advent Calendar 20日目です。

はじめに

こんにちは。ノバセルでデータエンジニアをしている小川です。

DagsterのSensorを使ってTimeout時の自動リトライ機能を作成したためサンプルコードでその実装方法と利点を解説しようと思います。

背景

ノバセルでは、顧客が求めるデータ(リクエスト)に応じて、dbtで定義したデータモデルをDagster上でマテリアライズ(データ生成・変換・格納)しています。このフロー全体をDagsterで管理することで、スケジュール実行や状態管理、リトライ制御などを一元的に行っています。

大まかな処理は以下の流れです。

flowchart LR
        A([dbtでリクエストデータを表すモデルを作成]) --> B[リクエストデータ]
        C[外部データ] --> D([Dagster Jobで処理])
        B --> D
        D --> E[Output]
        D --> F[完了済みリクエスト]
        F -.->|完了した処理は次回以降の処理対象にしない| A
  1. dbtで新たなリクエスト情報(処理対象のデータ範囲や条件など)を抽出し、処理要求を作成する。
  2. 上記のリクエスト情報をもとに、外部データを取得・変換するDagster Jobを実行し、成果物を生成する。
  3. 処理が完了したリクエストは「完了済み」として扱い、次回以降の処理対象から除外する。

このような仕組みのため、毎回の実行で扱うデータの内容や量が変動します。

顧客のリクエスト内容によって必要な外部データ量は大きく増える場合があり、その結果Dagster Jobの実行がTimeoutしてしまうケースが発生します。

dbtのvarsで指定する変数(たとえばcalculate_num)を用いると、一度に処理するデータ件数を制御できます。

vars:
    calculate_num: 10000
with fuga as (
    select * from {{ ref('hoge') }}
    limit {{ var('calculate_num') }}
)

これにより、Timeoutを回避するため必要に応じてデータ件数を減らすことは可能ですが、Timeoutのたびにこの変数を手動で調整し、再デプロイ・再実行するのは手間がかかります。

そこで、本記事ではDagsterのrun_failure_sensor機能を活用し、Timeoutが発生したときに自動で変数の値を下げてリトライを行う仕組みを構築する方法を紹介します。これにより、人手での再設定を減らし、よりスムーズな実行管理が可能になります。

使用する技術の説明

Dagster

Dagsterは、パイプラインやAsset(データ資産)をコードで定義し、スケジュール、モニタリング、エラー処理などを包括的に行えるデータオーケストレーションプラットフォームです。

私たちノバセルでも、データ処理の効率化と安定運用のためにDagsterを利用しています。

dbt

dbtは、SQLベースでデータウェアハウス上のデータモデルを定義・実行し、標準化・再現性の高いデータパイプラインを実現するツールです。

Dagsterとdbtの相性

Dagsterはデータパイプライン全体のオーケストレーションが得意で、dbtはデータ変換・モデリングに強みがあります。この2つを組み合わせることで、dbtによるモデル作成・更新をDagsterのパイプライン内で一元的にコントロールし、より高い信頼性と柔軟性を持ったデータパイプライン運用が可能となります。

デモを作成するにあたっての準備

この記事のデモは、公式チュートリアル「Using dbt with Dagster」のPart2まで完了した状態のものを改良して作っています。

Timeoutが起こるような重い処理を再現するため追加で以下を実施しました

  • orders, payments, customersのseedデータを各10000件になるように追加
  • 3つのstagingモデルをCROSS JOINするvery_heavy_model.sqlというモデルを追加
{{
  config(
    materialized = 'table',
    )
}}

with orders as (
    select * from {{ ref('stg_orders') }}
    limit {{ var('calculate_num') }}
)
, payments as (
    select * from {{ ref('stg_payments') }}
    limit {{ var('calculate_num') }}
)
, customers as (
    select * from {{ ref('stg_customers') }}
    limit {{ var('calculate_num') }}
)
, cross_joined as (
    select
        *
    from orders
    cross join payments
    cross join customers
)
select * from cross_joined
  • 変数calculate_numはデフォルト10000に設定
vars:
    calculate_num: 10000

very_heavy_model.sqlをbuildする際にcalculate_numを10000に設定すると計算量が多く、膨大な時間がかかります。

仕組みの概要

今回の要はDagsterのSensor機能、それもrun_failure_sensorです。
Sensorとは外部の状態変化を検知してJobの実行をトリガーする機能です。
その中でもrun_failure_sensorは名前の通りRunが失敗した時に反応するSensorを作成するためのものです。

実装

Asset側
完成系は以下の通りです。

from dagster import AssetExecutionContext, Config, define_asset_job
from dagster_dbt import DbtCliResource, dbt_assets

from .project import jaffle_shop_project

DEFAULT_CALCULATE_NUM = 2000
class MyAssetConfig(Config):
    calculate_num: int = DEFAULT_CALCULATE_NUM

@dbt_assets(manifest=jaffle_shop_project.manifest_path)
def jaffle_shop_dbt_assets(
        context: AssetExecutionContext,
        dbt: DbtCliResource,
        config: MyAssetConfig
    ):
    calculate_num = config.calculate_num
    build_cmd = ["build", "--vars", f'{{"calculate_num": {calculate_num}}}']
    yield from dbt.cli(build_cmd, context=context).stream()

dbt_job = define_asset_job(
    name="dbt_job",
    selection=[jaffle_shop_dbt_assets],
)

いくつかチュートリアルから手を加えています。

まず、MyAssetConfigについてです。これはRun Configuration(RunConfig)というものであり、Jobの実行時に値を指定することでJobの中でその値を使って動的に処理を行うことができます。 Configを継承することで好きな項目を追加することができます。
今回はcalculate_numを追加し、その値をdbt build--varsオプションに渡すことで動的にdbtの変数の値を変更しています。実際にRunConfigに値を指定する所はこのあと出てきます。

次にdefine_asset_jobです。Sensorから実行する対象として設定できるのはJobでありAssetではダメなのでdefine_asset_jobを使ってJobにしています。

Sensor側

こちらは順序を追って見ていきます。

まずrun_failure_sensorデコレータを使用し、ジョブが失敗した際にトリガーされるSensorを用意します。
どのJobが失敗した時にトリガーするかをmonitored_jobsに、このSensorから実行するJobをrequest_jobに指定します。
今回はdbt_jobがTimeoutで失敗した時にdbt_jobをリトライするためどちらにもdbt_jobを指定します。

from dagster import run_failure_sensor
from .assets import dbt_job

@run_failure_sensor(monitored_jobs=[dbt_job], request_job=dbt_job)
def retry_on_timeout(context: RunFailureSensorContext):
    ...

次にRunFailureSensorContextからエラーメッセージを取得し、Timeoutであるかどうかの判別をします。
Timeoutの時はRunRequestでリトライを、それ以外の場合にはSkipReasonでリトライをスキップします。

from collections.abc import Generator
from dagster import run_failure_sensor, RunRequest, SkipReason, RunFailureSensorContext

...

def retry_on_timeout(context: RunFailureSensorContext) -> Generator[RunRequest | SkipReason]:
    if _is_timeout(context=context):
        yield RunRequest()
    else:
        yield SkipReason("skip retry because this error is not timeout.")

def _is_timeout(context: RunFailureSensorContext) -> bool:
    failure_events = context.get_step_failure_events()
    for failure_event in failure_events:

        # failure_eventのevent_specific_dataにはエラーメッセージが入っているためそこでTimeoutかどうかを判別
        if "timeout" in str(failure_event.event_specific_data).lower() and failure_event.is_step_failure:
            return True
    return False

最後にリトライ時のrun_configを設定します。
失敗したRunのrun_configを取得し、それを半分にした値を設定してリトライするようにします。

from dagster import run_failure_sensor, RunRequest, SkipReason, RunConfig, RunFailureSensorContext
from .assets import dbt_job, MyAssetConfig, DEFAULT_CALCULATE_NUM

...

def retry_on_timeout(context: RunFailureSensorContext) -> Generator[RunRequest | SkipReason]:
    if _is_timeout(context=context):

        # 今実行されていたRunのcalculate_numを取得する
        previous_run = context.dagster_run
        run_config = previous_run.run_config
        previous_calculate_num = \
            run_config["ops"]["jaffle_shop_dbt_assets"]["config"]["calculate_num"] \
                if run_config else DEFAULT_CALCULATE_NUM

        # 以前のcalculate_numを半分にし、リトライ
        yield RunRequest(run_key=None ,run_config=RunConfig(
            ops={
                "jaffle_shop_dbt_assets": MyAssetConfig(
                    calculate_num=previous_calculate_num // 2
                )
            }
        ))
    else:
        ...

完成系は以下の通りです。

from collections.abc import Generator
from dagster import run_failure_sensor, RunRequest, SkipReason, RunConfig, RunFailureSensorContext
from .assets import dbt_job, MyAssetConfig, DEFAULT_CALCULATE_NUM

@run_failure_sensor(monitored_jobs=[dbt_job], request_job=dbt_job)
def retry_on_timeout(context: RunFailureSensorContext) -> Generator[RunRequest | SkipReason]:
    if _is_timeout(context=context):
        previous_run = context.dagster_run
        run_config = previous_run.run_config
        previous_calculate_num = \
            run_config["ops"]["jaffle_shop_dbt_assets"]["config"]["calculate_num"] \
                if run_config else DEFAULT_CALCULATE_NUM

        yield RunRequest(run_key=None ,run_config=RunConfig(
            ops={
                "jaffle_shop_dbt_assets": MyAssetConfig(
                    calculate_num=previous_calculate_num // 2
                )
            }
        ))
    else:
        yield SkipReason("skip retry because this error is not timeout.")

def _is_timeout(context: RunFailureSensorContext) -> bool:
    failure_events = context.get_step_failure_events()
    for failure_event in failure_events:
        if "timeout" in str(failure_event.event_specific_data).lower() and failure_event.is_step_failure:
            return True
    return False

この仕組みのメリット

  • 自動リトライで手間削減
    Timeoutが発生するたびに手動でlimitを変更しデプロイ・実行し直す必要がなくなります。Timeoutになったとしても処理数を減らしつつ勝手に処理が進んでいくので気を取られる回数が確実に減ります。
  • 成功後は元の状態に復帰
    limitの変更はSensorからの実行のみに限られ、次回以降はデフォルトの設定値に戻るため、処理数を減らしたことによるパフォーマンス低下を常に強いられることはありません。

考慮すべき点

  • リトライ上限の設定
    limitを下げ続けても改善しない場合、問題はデータ量以外の要因かもしれません。その場合は閾値を設け、ダメなら停止して代わりに通知する方が良いでしょう。
  • 同時実行管理
    リトライがスケジュール実行と重なると同時実行数が増え、パフォーマンス悪化につながる可能性があります。並列数やリソース配分を検討する必要があります。

締め

このように、Dagsterのrun_failure_sensorは、エラー発生時の動的なパラメータ調整と自動リトライを実現する強力な仕組みです。Sensorの機能を最大限活用し、面倒なところは自動化してツールに任せてしまいましょう。

Sensorは奥が深いので是非使ってみてください!

人間がプロンプトを磨く時代は終焉を迎える

この記事はノバセル Advent Calendar19日目です。

こんにちは。ノバセルでエンジニアをしている浅田です。 ここではプロンプトエンジニアリングの今と未来について考察していきたいと思います。

はじめに

近年、自然言語処理の飛躍的な進歩に伴い、プロンプトエンジニアリング(Prompt Engineering)は大型言語モデルを効果的に活用するための中核的な手法として注目を集めています。プロンプトエンジニアリングは、モデル自体を再訓練せずに、入力となる「指示(プロンプト)」を巧みに設計・調整することで、目的に適した高品質な出力を引き出すアプローチです。

しかし、これまでは主に人力でプロンプトを設計しており、その過程には多くの試行錯誤や専門的な知識が求められていました。また、モデルが更新されるたびに再最適化を行う必要があるなど、非効率的な側面も明らかになっていました。

本稿では、従来の課題を整理した上で、技術の進化による解決策と、今後の展望について整理し、深く掘り下げて考察します。

1. 手動によるプロンプトエンジニアリングの課題

1.1 効率性の問題

  • 試行錯誤の増大: タスクごとにプロンプトを微調整する作業は労力と時間を要し、極めて非効率的です。
  • スケール不可能性: 利用ケースが増えるにつれ、全てを人手で対応することは現実的でなくなります。

1.2 専門知識への依存

  • モデル特性の理解要件: 効果的なプロンプトを作るにはモデル構造や潜在的能力への深い理解が不可欠です。
  • タスク領域知識の必要性: 問題領域を把握していないと、的外れなプロンプトや不十分な指示が生じます。

1.3 入力設計の繊細さ

  • 微細な表現差の影響: わずかな言い回しの変更で結果が大きく変動し、誤った出力が頻発します。
  • 品質担保困難: シビアな入力管理を求められ、品質安定化には多大な労力が必要です。

1.4 モデル更新による再最適化

  • アップデートのたびの再作業: モデルバージョンが変わるたび、過去の最適化が無効化され、新たな最適化が必要となります。

2. 課題を解決する技術進化

これらの課題を克服するため、プロンプトエンジニアリングを取り巻く技術は劇的に進化しています。以下では解決策を4つの視点から整理します。

2.1 モデル自体の高性能化

  • 簡易指示への対応: GPT-4などのモデルは「要約して」「翻訳して」といったシンプルなプロンプトでも期待通りの出力を返します。
  • 文脈理解力向上: 曖昧な指示でも文脈から適切な処理を行うため、プロンプト設計に割く労力が減少します。

www.sciencedirect.com 上記の記事を見ると、「It has been noted that GPT-4 better understands prompts than Google AI, even with verbal flaws, and tolerates grammatical errors.」とあります。 GPT-4が不完全なプロンプトに対しても正確に対応できることが示されています。

2.2 自動最適化技術の進化

  • AutoPrompt: AI自らが課題の本質を掴み、最適なプロンプトを自動生成する技術が登場しています。

arxiv.org

AutoPromptの手法は以下の手順で構成されています:

  1. タスクの再構築

    • 与えられたタスク(例: 感情分析)を「穴埋め問題」(fill-in-the-blank)形式に変換。
    • 例: 「This movie is [MASK].」のようにプロンプトを構成。
  2. トリガートークンの追加

    • プロンプトに共通の「トリガートークン」(例: atmosphere, dialogueなど)を挿入。
    • トリガートークンは、モデルが予測を改善するよう最適化される。
  3. 勾配ベースの探索

    • トリガートークンを初期状態([MASK])から始め、勾配情報を利用してトークンを選択。
    • トリガートークンを変更しながらラベルの予測確率を最大化する。
  4. ラベルトークンの選択

    • 特定のクラス(例: 感情分析の「positive」や「negative」)に対応する単語(例: fantastic, terrible)を自動で選定。
    • [MASK]トークンの予測確率を基に、関連性の高い単語をラベルとして設定。
  5. プロンプトの評価と更新

    • 構築したプロンプトをモデルに与え、予測精度を評価。
    • 最適なプロンプトを選び、タスクの実行に利用。

この手法により、手動設計の労力を削減し、さまざまなタスクに効率的に適応可能なプロンプトが生成できます。

AutoPromptの実装例には以下のようなものがあります。

github.com

2.3 分野特化型LLMの登場

  • 分野特化モデル: 医療、法務など領域特化AIは、その分野特有の知識を事前に内包し、複雑な指示不要で高度な応答を実現します。

sites.research.google

Med-PaLMは、Googleが開発した医療特化型大規模言語モデル(LLM)です。PaLMを基に医学論文を活用してトレーニングされ、医療現場での質問応答や診断支援を提供します。

2.4 補助ツールや支援システムの普及

  • テンプレートライブラリ: 汎用的な指示テンプレートが用意されており、非専門家でも標準化されたプロンプトを容易に利用可能です。
  • プロンプト生成支援ツール: AIがユーザーの目標に合わせて最適なプロンプトを提案するため、手動による試行錯誤が大幅に軽減されます。

www.youtube.com

上記はClaudeで提供されているprompt improverという機能です。これを利用することで、人間が生成した粗末なプロンプトをClaudeがprompt engineering techniquesに則って改良してくれます。

おわりに

プロンプトエンジニアリングは、モデル性能向上や自動最適化技術の進化、タスク特化型AIの普及、そして補助ツール群の充実により、従来とは全く異なるかたちへとシフトしつつあります。

これにより、個別の人手による細かな指示設計の必要性は薄れ、より高度な活用戦略や創造的な業務への人間のリソースシフトが可能になります。AIと人間が互いを補完し合い、新たな価値創出へと進む未来はすでに見え始めているのです。

React Hooks 深掘り:useClickAway と 最新のRef パターンについて理解する

この記事は ノバセル Advent Calendar 18 日目です。

ノバセル新卒3年目の田村(tamtam)です。最近ではJapanglish Techの主催をしています。

この記事では、React Hooksの一つである useClickAway フックについて深掘りし、コールバック関数の最新化をイベントリスナーの更新から分離させる「最新の Ref パターン」について詳しく解説します。これにより、stale closure(古い値の参照)の回避など、効果的な利点について学びましょう。

1. useClickAwayとは

useClickAwayフックは、react-useで提供されるReact Hooksの1つです。

指定した要素の外側でクリックやタッチイベントが発生した際に特定のコールバックを実行するために設計されています。

主にドロップダウンメニュー、モーダル、ツールチップなどを閉じる際に利用されます。このフックの実装を確認し、その背後にある設計思想と利点を理解しましょう。

useClickAwayの動作の説明

以下は、useClickAway のコードです

github.com

import { useEffect, useRef } from 'react';

function useClickAway(ref, onClickAway, events = ['mousedown', 'touchstart']) {
  const savedCallback = useRef(onClickAway);

  useEffect(() => {
    savedCallback.current = onClickAway;
  }, [onClickAway]);

  useEffect(() => {
    const handler = (event) => {
      const { current: el } = ref;
      if (el && !el.contains(event.target)) {
        savedCallback.current(event);
      }
    };
    for (const eventName of events) {
      document.addEventListener(eventName, handler);
    }
    return () => {
      for (const eventName of events) {
        document.removeEventListener(eventName, handler);
      }
    };
  }, [events, ref]);
}

export default useClickAway;

詳細な動作説明

  1. savedCallback の初期化:

    初回レンダリング時に、savedCallback.currentonClickAway が設定されます。useRef を使用することで、この参照は再レンダリング前後で保持されます。

  2. useEffect による savedCallback の更新:

    onClickAway が変更されるたびに、この useEffect が実行され、savedCallback.current が最新の onClickAway に更新されます。これにより、イベントリスナー内で常に最新のコールバックが呼び出されます。

  3. イベントリスナーの設定 (useEffect の第二のフック):

    eventsref に変更があった場合にのみ実行されます。

    ref は、監視対象となるDOM要素への参照を保持するために使用されます。useClickAway フックに渡す ref は、外部クリックを検知したい要素を指し示す必要があります。

    events は、どのイベントを監視するかを指定する配列です。デフォルトでは ['mousedown', 'touchstart'] が設定されていますが、必要に応じてカスタマイズ可能(例: keydown)です。

    このフック内で定義された handler 関数は、一度だけ定義され、指定されたイベントに対して登録されます。handler 関数内では、常に savedCallback.current(最新の onClickAway)が呼び出されます。

  4. 再レンダリングとの関係:

    savedCallback.current の更新は useRef を介して行われるため、savedCallback.current の変更自体は再レンダリングを引き起こしません。 イベントリスナーの設定・解除も、useEffect の依存配列に onClickAway を含めていないため、onClickAway の変更によって再登録されることはありません。


2. useClickAwayで利用されている「最新の Ref パターン」について

useClickAway の実装において、useRef を活用した「最新の Ref パターン」が採用されています。このパターンの目的とその必要性について詳しく見ていきましょう。

i.再レンダリングを防ぐことによるパフォーマンスの向上

useClickAway では、イベントリスナーを設定する際にコールバック関数を直接渡すのではなく、useRef を用いて最新のコールバックを保持しています。

これにより、コールバック関数が変更されても、イベントリスナーは一度だけ設定され、savedCallback.current を通じて最新のコールバックが呼び出されます。

結果として、毎回イベントリスナーを削除して再登録する手間が省け、パフォーマンスが向上します。

ii.stale closure(古い値の参照)によるバグを防ぐ

「最新の Ref パターン」を使用する最大の利点は、stale closureによるバグを防ぐことです。stale valueについては次のセクションで詳しく解説します。

軽く説明すると、useRef を用いることで、非同期コールバック内でも常に最新のステートやプロパティにアクセスできるため、古い値を参照してしまう問題を回避できます。

  • 最新のコールバックへのアクセス:

    savedCallback.current を介して最新のコールバック関数にアクセスすることで、非同期イベントが発生した際にも、最新のステートやプロパティを反映した処理を実行できます。

  • クロージャによる古い値の回避:

    非同期コールバックが古いレンダー時点のステートをキャプチャする問題を、useRef を利用することで解消します。これにより、常に最新の値を参照でき、バグの発生を防止します。

補足: useClickAway における Stale Closure の具体例について

useClickAway のユースケースにおいて、「stale closure」によるバグが発生する具体的なケースは、実際にはあまり一般的ではありません。

これは、useClickAway が主に外部クリックを検知して特定のコールバックを実行するため、コールバック自体が頻繁にステートに依存するような状況が少ないためです。

しかし、理論的には以下のような状況で useClickAway においても stale closure の問題が発生する可能性があります:

  • 動的なコールバック関数:

    useClickAway に渡すコールバック関数が、コンポーネントのステートやプロパティに依存しており、そのステートが更新されるたびに新しいコールバックが必要な場合です。

例えば、外部クリック時に特定のステートを更新するような複雑なロジックを持つコールバック関数です。

  • 非同期処理との組み合わせ:

    コールバック関数内で非同期処理(例えば、API呼び出しや setTimeout)を行う場合、レンダー後にステートが更新されても、非同期処理内で古いステートが参照される可能性があります。


3. stale closure(古い値の参照)の回避の深掘り

React の関数コンポーネントにおいて、非同期コールバック(例えば、setTimeout やイベントリスナー内の関数)内でステートやプロパティを参照すると、古いレンダー時の値(stale value)を参照することがあります。

これがなぜ起こるのか、そしてそれがどのようなバグを引き起こす可能性があるのかについて詳しく解説します。

a. stale valueとは何か?

stale value とは、最新のステートやプロパティではなく、以前のレンダー時点の値を指します。

React の関数コンポーネントでは、コンポーネントが再レンダリングされるたびに、関数が再評価され、新しいステートやプロパティの値が反映されます。

しかし、非同期コールバック内では、以前のレンダー時点の値を閉じ込める(キャプチャする)ことがあります。

b. 非同期コールバック内でstale valueが発生する理由

i. クロージャの性質

JavaScript のクロージャは、関数が定義されたスコープ内の変数への参照を保持します。

React の関数コンポーネントにおいて、各レンダーは独自のスコープを持つため、非同期コールバックはそのレンダー時点のステートやプロパティにアクセスします。

ii. 非同期性によるタイミングのズレ

setTimeout やイベントリスナーのコールバックは、非同期に実行されるため、関数コンポーネントのレンダー後に呼び出されます。

このタイミングのズレにより、コールバック内で参照されるステートやプロパティが、コールバックが設定された時点のものとなり、後から更新された最新の値を反映できません。


4. コード例での理解

以下のコード例を見てみましょう:

引用:Hooks FAQ - Why am I seeing stale props or state inside my function?

import React, { useState } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

export default Example;

注意: このコードはあくまでもstale valueの説明であり、useClickAway のレンダリング最小化とは別の話です。useState を使用しているため、レンダリングの最小化は実現できません。

動作の流れ

  1. 初期レンダー:
    • count0 に設定されています。
    • 「Show alert」ボタンをクリックすると、3秒後に「You clicked on: 0」というアラートが表示されます。
  2. アラート表示前にカウントを増やす:
    • 「Show alert」をクリックし、その後すぐに「Click me」を複数回クリックして count を増やします。
    • しかし、アラートは「You clicked on: 0」のまま表示されます。

なぜアラートが古い値を表示するのか?

handleAlertClick 関数内の setTimeout のコールバックは、handleAlertClick が呼び出された時点の count の値を閉じ込めています。

そのため、非同期に実行されるコールバックは、count が更新されても最初に閉じ込められた 0 の値を表示します。


5. stale valueによるバグの例

stale valueを参照することは、以下のような予期せぬバグを引き起こすことがあります。

a. フォームの送信

ユーザーがフォームを入力し、非同期でデータを送信する場合、送信時に古いステートを参照すると、最新の入力内容を反映しないことがあります。

これにより、ユーザーの意図しないデータを送信するリスクがあります。

b. リアルタイムフィードバック

リアルタイムでフィードバックを提供する機能(例えば、検索フィルターやリアルタイムチャット)において、古いステートを参照すると、最新のユーザー入力に基づいたフィードバックが提供されなくなります。

これにより、ユーザー体験を損なう可能性があります。


6. stale valueのバグを防ぐ方法

stale valueを防ぐためには、以下のような方法があります。

useRef を使用する「最新の Ref パターン

useRef を使って最新のコールバックやステートを保持し、非同期コールバック内で参照します。これにより、常に最新の値にアクセスできます。

実装例

import React, { useState, useRef, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + countRef.current);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

export default Example;

解説

  • useRef の利用:

    countRefuseRef を用いて作成され、初期値として count を保持します。

  • useEffectcountRef を更新:

    count が変更されるたびに、countRef.current を最新の count に更新します。これにより、非同期コールバック内でも常に最新の count を参照できます。

  • 非同期コールバック内で countRef.current を参照:

    setTimeout のコールバック内で countRef.current を参照することで、最新の count 値を取得できます。

この方法の利点:

  • stale valueの回避:

    非同期コールバックが常に最新の count を参照するため、古い値を表示する問題を防げます。

  • 再レンダリングの最小化:

    useRef の更新は再レンダリングを引き起こさないため、パフォーマンスに優れています。

カスタムフックの利用: useLatest

「最新の Ref パターン」を応用したカスタムフックであるuseLatestを利用することで、コードの再利用性と可読性を向上させることができます。

useLatest フックの実装

github.com

import { useRef } from 'react';

const useLatest = <T>(value: T): { readonly current: T } => {
  const ref = useRef(value);
  ref.current = value;
  return ref;
};

export default useLatest;

使用例

import React, { useState } from 'react';
import useLatest from './useLatest';

function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useLatest(count);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + latestCount.current);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

export default Example;

解説

  • useLatest フック:

    任意の値を受け取り、その最新の値を ref に保持します。useRef を使用して ref を作成し、毎回レンダリング時に ref.current に最新の値を直接代入することで、最新の値を保持します。

  • 非同期コールバック内での利用:

    latestCount.current を参照することで、最新の count を取得できます。これにより、非同期コールバック内でもstale valueを避けることができます。

この方法の利点:

  • コードの再利用性:

    useLatest を他のコンポーネントでも簡単に利用でき、同様の問題を解決できます。

  • 可読性の向上:

    コールバック内で直接 ref を操作する必要がなくなり、コードがシンプルになります。


7. まとめ

React Hooksを活用することで、関数コンポーネント内で強力かつ柔軟なロジックを実装できます。

特に、useClickAway フックと「最新の Ref パターン」を組み合わせることで、イベントリスナーの管理を効率化し、stale valueによるバグを防ぐことが可能です。

以下に、今回解説したポイントをまとめます。

  • useClickAway フック:

    指定した要素の外側でのクリックやタッチイベントを検知し、特定のコールバックを実行します。主にドロップダウンメニューやモーダルの閉鎖に利用されます。

  • 最新の Ref パターン:

    useRef を用いて最新のコールバックやステートを保持し、非同期コールバック内で参照します。これにより、stale valueによるバグを防ぎつつ、イベントリスナーの再登録を最小限に抑えることができます。

  • stale valueの理解と回避:

    stale valueとは、非同期コールバック内で古いレンダー時点のステートやプロパティを参照してしまう問題です。useRef による最新のRefパターンやuseLatestを活用することで、この問題を効果的に回避できます。

この記事を通じて、useClickAway フックと最新の Ref パターンについての理解が深まり、React開発における実践的なスキル向上に役立てていただければ幸いです。


参考リンク

データサイエンティストがビジネス職寄りの仕事をやってみて感じたこと

この記事は ノバセル Advent Calendar17日目です。

こんにちは。ノバセルでデータサイエンティストをしている松村です。

本記事では今年1年間の自分の仕事を振り返りつつ、分類としてはエンジニア職にあたる自分がビジネス色の強い業務をやってみた実感について話していければと思います。

データサイエンティストに必要なビジネス力

データサイエンティストなら誰もが一度は見たことがある図と思いますが、データサイエンティストの能力は大きく3つのスキルセットで構成されていると言われています。

データサイエンティスト協会『2023年度スキル定義委員会活動報告』から引用

ここではタイトルにもある通りビジネス力について見ていきます。上図では「課題背景を理解したうえでビジネス課題を整理し、解決する力」とされています。

ここでの「課題」についても、1プロジェクトの中での短期的な課題もあれば、プロダクトやサービスの根底にある長期的な課題などあると思いますが、ここでは特に区別しないこととします。

ただ、データサイエンスによる課題解決といっても業界や会社におけるデータサイエンス組織の立ち位置などによって論点が変わるなので、まずは私の所属しているデータサイエンス組織について簡単に記載します。

  • マーケティング(主に広告)業界の事業会社
    • いわゆるベンチャー企業
  • 様々な事業がある中で、現在はデータ分析を納品物とするプロダクトに関わることが多い
    • データサイエンスチームとしてもプロダクトの売上や利益に紐づく成果が期待されている

つまり、研究機関や調査チームというよりは、販売しているプロダクトやサービスの中身に携わる機会が多い組織といえます。

私が所属するデータサイエンスチームでは上図で言われているような「課題背景を理解したうえでビジネス課題を整理」することは全員が意識して、日常的に会話がされています。

とはいえ、実際にプロダクトの企画や営業をするのはいわゆるビジネス職の方々なので、プロダクトの課題といった長期的な課題に対峙する際は、ビジネス職の方々とのコミュニケーションを密に取る必要があります。

その中で、今年はデータサイエンティストのコア領域をやや越境して、ビジネス色の強い業務に携わる機会をもらえました。

なお、そもそもデータサイエンティスト(ひいてはエンジニア職)にビジネス力が必要、という話はすでに深い考察が多くありますので、本記事ではそこについては触れず、実際にビジネス職寄りの仕事をしてみた実感について話していければと思います。

tjo.hatenablog.com

note.com

今年チャレンジしたビジネス側の仕事

今年、自分はあるデータサイエンス起点のプロダクトについてオーナーシップを持つことになり、以下のような仕事にチャレンジしました。

  • 営業に同行しながら
    • 顧客からくる技術的な質問への返答
    • 顧客課題と現状のプロダクトに足りない部分の把握
  • データアナリストとの分析プロジェクトで
    • アウトプットクオリティ管理
      • 分析の中身について統計学的観点からレビュー
      • 事業部の予算に対して正しく計上できそうかなど、数値面の管理

つまり、業務内容としてはPM、PdMやセールスエンジニアのようなことを広く行いました。

その中で顧客の声を多く聞いたり、プロダクトそのもののあり方(利益の出し方や、社内での立ち位置)を事業部側と深く議論するといった、ビジネス側に近い仕事にチャレンジした1年でした1

これにより一定の受注率への貢献できたり、チーム内でプロダクトについて議論が深まるといった周りに対しての影響と、個人の成長という2面で良い結果を残せたのかなと思っています。

感じたこと

データサイエンティストにとってのビジネス力

自分はデータサイエンティストのベン図でいうと、どちらかというとデータサイエンスとデータエンジニアリングに強く、ビジネス力にあまり強くはありませんでした。そのため、今回の挑戦を振り返るとコンフォートゾーンを飛び出てラーニングゾーンやストレッチゾーンと言われるところに身を置けたのかなと思っています。

『セルフマネジメントに欠かせない”5つのゾーン”に対する捉え方』から引用

今回ビジネス職に近い仕事にチャレンジした実感をまとめると以下のようになります、

  • 顧客に直接向き合う仕事を少しでもしたほうが、データサイエンティストとしての価値は上がる
  • 技術営業やプロダクトマネジメントなど、データサイエンスから一見コア技術から離れて見える仕事でも顧客と向き合うことでデータサイエンス視点から新たなコア技術の種を発見できる
    • 視野も広がるし総合力も上がる
  • 同時にこれを専門でやり続けているビジネス職の方々とのお互いのリスペクトに繋がった

参考記事・URL


  1. それでも自分は現時点ではエンジニア職であるため、営業職や事業開発職のように予算を持っていたわけではありません。